Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fcb5086132 |
@@ -1,167 +0,0 @@
|
||||
// @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> = {}): 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(<PendingAttachmentPill file={file} onRemove={vi.fn()} />);
|
||||
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(<PendingAttachmentPill file={file} onRemove={vi.fn()} />);
|
||||
expect(screen.getByText("512 B")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("displays formatted size in KB", () => {
|
||||
const file = new File([new Uint8Array(5 * 1024)], "medium.zip");
|
||||
render(<PendingAttachmentPill file={file} onRemove={vi.fn()} />);
|
||||
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(<PendingAttachmentPill file={file} onRemove={vi.fn()} />);
|
||||
// formatSize uses toFixed(1) for MB → "1.5 MB"
|
||||
expect(screen.getByText("1.5 MB")).toBeTruthy();
|
||||
});
|
||||
|
||||
it('× button has aria-label "Remove <filename>"', () => {
|
||||
const file = makeFile("memo.pdf", 1_000);
|
||||
render(<PendingAttachmentPill file={file} onRemove={vi.fn()} />);
|
||||
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(<PendingAttachmentPill file={file} onRemove={onRemove} />);
|
||||
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(<PendingAttachmentPill file={file} onRemove={vi.fn()} />);
|
||||
const buttons = screen.getAllByRole("button");
|
||||
expect(buttons).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── AttachmentChip ────────────────────────────────────────────────────────
|
||||
|
||||
describe("AttachmentChip", () => {
|
||||
let onDownload: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
onDownload = vi.fn();
|
||||
});
|
||||
|
||||
describe("renders", () => {
|
||||
it("displays the attachment name", () => {
|
||||
const att = makeAttachment({ name: "analysis.csv" });
|
||||
render(<AttachmentChip attachment={att} onDownload={onDownload} tone="agent" />);
|
||||
expect(screen.getByText("analysis.csv")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("displays the download glyph (SVG icon) inside the button", () => {
|
||||
const att = makeAttachment();
|
||||
render(<AttachmentChip attachment={att} onDownload={onDownload} tone="agent" />);
|
||||
const button = screen.getByRole("button");
|
||||
// DownloadGlyph is an <svg aria-hidden="true"> inside the button
|
||||
const svg = button.querySelector("svg");
|
||||
expect(svg).not.toBeNull();
|
||||
});
|
||||
|
||||
it("displays size when provided", () => {
|
||||
const att = makeAttachment({ size: 41_000 }); // ~40 KB
|
||||
render(<AttachmentChip attachment={att} onDownload={onDownload} tone="agent" />);
|
||||
// 41 000 / 1024 ≈ 40 → "40 KB"
|
||||
expect(screen.getByText("40 KB")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("omits size span when size is undefined", () => {
|
||||
const att = makeAttachment({ size: undefined });
|
||||
render(<AttachmentChip attachment={att} onDownload={onDownload} tone="agent" />);
|
||||
// "KB" should not appear; only the name + download glyph are visible
|
||||
expect(screen.queryByText(/KB/i)).toBeNull();
|
||||
});
|
||||
|
||||
it('has title attribute for hover tooltip', () => {
|
||||
const att = makeAttachment({ name: "readme.md" });
|
||||
render(<AttachmentChip attachment={att} onDownload={onDownload} tone="agent" />);
|
||||
const button = screen.getByRole("button");
|
||||
expect(button.getAttribute("title")).toBe("Download readme.md");
|
||||
});
|
||||
|
||||
it("calls onDownload with the attachment when clicked", () => {
|
||||
const att = makeAttachment({ name: "data.json" });
|
||||
render(<AttachmentChip attachment={att} onDownload={onDownload} tone="agent" />);
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
expect(onDownload).toHaveBeenCalledTimes(1);
|
||||
expect(onDownload).toHaveBeenCalledWith(att);
|
||||
});
|
||||
|
||||
it("tone=user applies blue-400 accent class", () => {
|
||||
const att = makeAttachment();
|
||||
render(<AttachmentChip attachment={att} onDownload={onDownload} tone="user" />);
|
||||
const button = screen.getByRole("button");
|
||||
// The user tone includes blue-400/blue-100 accent classes.
|
||||
// We check the rendered class string includes the accent class.
|
||||
expect(button.className).toMatch(/blue-400/);
|
||||
});
|
||||
|
||||
it("tone=agent omits blue-400 accent class", () => {
|
||||
const att = makeAttachment();
|
||||
render(<AttachmentChip attachment={att} onDownload={onDownload} tone="agent" />);
|
||||
const button = screen.getByRole("button");
|
||||
expect(button.className).not.toMatch(/blue-400/);
|
||||
});
|
||||
|
||||
it("renders exactly one button (no duplicate download targets)", () => {
|
||||
const att = makeAttachment({ name: "budget.xlsx", size: 80_000 });
|
||||
render(<AttachmentChip attachment={att} onDownload={onDownload} tone="user" />);
|
||||
const buttons = screen.getAllByRole("button");
|
||||
expect(buttons).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,300 @@
|
||||
"""Test coverage for shared_runtime helpers (issue #366).
|
||||
|
||||
Six helper functions previously had zero test coverage:
|
||||
_extract_part_text, extract_message_text, format_conversation_history,
|
||||
build_task_text, append_peer_guidance, brief_task
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
from shared_runtime import (
|
||||
_extract_part_text,
|
||||
append_peer_guidance,
|
||||
brief_task,
|
||||
build_task_text,
|
||||
extract_message_text,
|
||||
format_conversation_history,
|
||||
)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# _extract_part_text
|
||||
# =============================================================================
|
||||
|
||||
class TestExtractPartText:
|
||||
"""Coverage for shared_runtime._extract_part_text()."""
|
||||
|
||||
def test_dict_with_text_field(self):
|
||||
assert _extract_part_text({"text": "hello"}) == "hello"
|
||||
|
||||
def test_dict_without_text_field(self):
|
||||
assert _extract_part_text({"type": "image"}) == ""
|
||||
|
||||
def test_dict_with_empty_text_field(self):
|
||||
assert _extract_part_text({"text": ""}) == ""
|
||||
|
||||
def test_dict_with_root_nesting(self):
|
||||
"""Text buried in part['root']['text'] is extracted."""
|
||||
assert _extract_part_text({"root": {"text": "nested"}}) == "nested"
|
||||
|
||||
def test_dict_with_root_non_dict(self):
|
||||
"""part['root'] that is not a dict is safely skipped."""
|
||||
assert _extract_part_text({"root": "string", "text": "top"}) == "top"
|
||||
|
||||
def test_object_with_text_attribute(self):
|
||||
class FakePart:
|
||||
text = "attr-text"
|
||||
|
||||
assert _extract_part_text(FakePart()) == "attr-text"
|
||||
|
||||
def test_object_with_root_object_with_text(self):
|
||||
"""Object with root.attr.text is extracted (A2A v1 object style)."""
|
||||
|
||||
class FakeRoot:
|
||||
text = "root-attr-text"
|
||||
|
||||
class FakePart:
|
||||
root = FakeRoot()
|
||||
|
||||
assert _extract_part_text(FakePart()) == "root-attr-text"
|
||||
|
||||
def test_object_with_empty_text_attribute(self):
|
||||
class FakePart:
|
||||
text = ""
|
||||
|
||||
assert _extract_part_text(FakePart()) == ""
|
||||
|
||||
def test_none_input(self):
|
||||
assert _extract_part_text(None) == ""
|
||||
|
||||
def test_unexpected_type(self):
|
||||
"""Plain int/float/bool falls through to empty string."""
|
||||
assert _extract_part_text(42) == ""
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# extract_message_text
|
||||
# =============================================================================
|
||||
|
||||
class TestExtractMessageText:
|
||||
"""Coverage for shared_runtime.extract_message_text()."""
|
||||
|
||||
def test_list_of_dict_parts(self):
|
||||
parts = [{"text": "hello"}, {"text": "world"}]
|
||||
assert extract_message_text(parts) == "hello world"
|
||||
|
||||
def test_single_part(self):
|
||||
assert extract_message_text([{"text": "single"}]) == "single"
|
||||
|
||||
def test_context_object_with_message_parts(self):
|
||||
"""RequestContext-like: .message.parts is the parts list."""
|
||||
|
||||
class FakeContext:
|
||||
class _Msg:
|
||||
parts = [{"text": "from context"}]
|
||||
|
||||
message = _Msg()
|
||||
|
||||
assert extract_message_text(FakeContext()) == "from context"
|
||||
|
||||
def test_context_object_without_message(self):
|
||||
"""No .message attr → falls back to treating input as a parts list."""
|
||||
|
||||
class FakeContext:
|
||||
pass # no .message
|
||||
|
||||
# Pass a list directly as the context-like object
|
||||
assert extract_message_text([{"text": "fallback"}]) == "fallback"
|
||||
|
||||
def test_whitespace_normalized(self):
|
||||
"""Leading/trailing whitespace is stripped; internal newlines are preserved."""
|
||||
parts = [{"text": " hello "}, {"text": "\nworld\n"}]
|
||||
result = extract_message_text(parts)
|
||||
# Leading/trailing stripped, but internal \n stays (join uses single space)
|
||||
assert result == "hello \nworld"
|
||||
assert not result.startswith(" ")
|
||||
assert not result.endswith(" ")
|
||||
|
||||
def test_empty_parts_list(self):
|
||||
assert extract_message_text([]) == ""
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# format_conversation_history
|
||||
# =============================================================================
|
||||
|
||||
class TestFormatConversationHistory:
|
||||
"""Coverage for shared_runtime.format_conversation_history()."""
|
||||
|
||||
def test_single_user_message(self):
|
||||
hist = [("human", "hello")]
|
||||
out = format_conversation_history(hist)
|
||||
assert out == "User: hello"
|
||||
|
||||
def test_single_agent_message(self):
|
||||
hist = [("ai", "response")]
|
||||
out = format_conversation_history(hist)
|
||||
assert out == "Agent: response"
|
||||
|
||||
def test_interleaved_history(self):
|
||||
hist = [
|
||||
("human", "hello"),
|
||||
("ai", "hi there"),
|
||||
("human", "what is 2+2?"),
|
||||
("ai", "four"),
|
||||
]
|
||||
out = format_conversation_history(hist)
|
||||
lines = out.split("\n")
|
||||
assert lines[0] == "User: hello"
|
||||
assert lines[1] == "Agent: hi there"
|
||||
assert lines[2] == "User: what is 2+2?"
|
||||
assert lines[3] == "Agent: four"
|
||||
|
||||
def test_empty_history(self):
|
||||
assert format_conversation_history([]) == ""
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# build_task_text
|
||||
# =============================================================================
|
||||
|
||||
class TestBuildTaskText:
|
||||
"""Coverage for shared_runtime.build_task_text()."""
|
||||
|
||||
def test_no_history_returns_user_message_unchanged(self):
|
||||
assert build_task_text("do the thing", []) == "do the thing"
|
||||
|
||||
def test_history_prepends_transcript(self):
|
||||
hist = [("human", "hello"), ("ai", "hi")]
|
||||
result = build_task_text("follow-up", hist)
|
||||
assert "Conversation so far:" in result
|
||||
assert "User: hello" in result
|
||||
assert "Agent: hi" in result
|
||||
assert "follow-up" in result
|
||||
|
||||
def test_user_message_after_conversation_header(self):
|
||||
hist = [("human", "hello")]
|
||||
result = build_task_text("do it", hist)
|
||||
assert result.startswith("Conversation so far:")
|
||||
assert result.endswith("Current request: do it")
|
||||
|
||||
def test_empty_user_message_with_history(self):
|
||||
"""Empty user_message is still rendered with history."""
|
||||
hist = [("human", "hello")]
|
||||
result = build_task_text("", hist)
|
||||
assert "Conversation so far:" in result
|
||||
assert "Current request:" in result
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# append_peer_guidance
|
||||
# =============================================================================
|
||||
|
||||
class TestAppendPeerGuidance:
|
||||
"""Coverage for shared_runtime.append_peer_guidance()."""
|
||||
|
||||
def test_base_text_appended(self):
|
||||
result = append_peer_guidance(
|
||||
"base text",
|
||||
peers_info="alpha: ws-1",
|
||||
default_text="default",
|
||||
tool_name="delegate_task",
|
||||
)
|
||||
assert result.startswith("base text")
|
||||
assert "## Peers" in result
|
||||
assert "alpha: ws-1" in result
|
||||
assert "Use delegate_task" in result
|
||||
|
||||
def test_null_base_text_uses_default(self):
|
||||
result = append_peer_guidance(
|
||||
None,
|
||||
peers_info="peer info",
|
||||
default_text="DEFAULT_TEXT",
|
||||
tool_name="tool",
|
||||
)
|
||||
assert result.startswith("DEFAULT_TEXT")
|
||||
|
||||
def test_whitespace_base_text_strips_to_empty_peers_still_added(self):
|
||||
"""Whitespace-only base_text is stripped but default_text is NOT used
|
||||
(only None triggers the fallback). The peers section is still appended."""
|
||||
result = append_peer_guidance(
|
||||
" ",
|
||||
peers_info="peer",
|
||||
default_text="DEF",
|
||||
tool_name="t",
|
||||
)
|
||||
# " ".strip() == ""; default_text is NOT substituted for whitespace
|
||||
assert "## Peers" in result
|
||||
assert "peer" in result
|
||||
assert "DEF" not in result # default_text only on None, not whitespace
|
||||
|
||||
def test_none_base_text_uses_default(self):
|
||||
"""None base_text triggers fallback to default_text."""
|
||||
result = append_peer_guidance(
|
||||
None,
|
||||
peers_info="peer",
|
||||
default_text="DEFAULT",
|
||||
tool_name="tool",
|
||||
)
|
||||
assert result.startswith("DEFAULT")
|
||||
assert "## Peers" in result
|
||||
|
||||
def test_empty_peers_info_skips_section(self):
|
||||
result = append_peer_guidance(
|
||||
"base",
|
||||
peers_info="",
|
||||
default_text="def",
|
||||
tool_name="tool",
|
||||
)
|
||||
# No "## Peers" section when peers_info is empty
|
||||
assert result == "base"
|
||||
|
||||
def test_whitespace_in_base_and_peers_normalized(self):
|
||||
result = append_peer_guidance(
|
||||
" base \n",
|
||||
peers_info=" peer-1 \n",
|
||||
default_text="def",
|
||||
tool_name="tool",
|
||||
)
|
||||
# Base should be stripped of leading/trailing whitespace
|
||||
assert result.startswith("base")
|
||||
# Peer info should be appended
|
||||
assert "peer-1" in result
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# brief_task
|
||||
# =============================================================================
|
||||
|
||||
class TestBriefTask:
|
||||
"""Coverage for shared_runtime.brief_task()."""
|
||||
|
||||
def test_short_text_returned_unchanged(self):
|
||||
assert brief_task("hello", limit=60) == "hello"
|
||||
|
||||
def test_exact_limit_no_ellipsis(self):
|
||||
text = "A" * 60
|
||||
assert brief_task(text, limit=60) == text
|
||||
assert "..." not in text
|
||||
|
||||
def test_truncated_with_ellipsis(self):
|
||||
text = "A" * 80
|
||||
result = brief_task(text, limit=60)
|
||||
assert len(result) == 63 # 60 chars + "..."
|
||||
assert result.endswith("...")
|
||||
|
||||
def test_limit_10_shortens(self):
|
||||
result = brief_task("hello world", limit=10)
|
||||
assert len(result) == 13 # 10 chars + "..."
|
||||
assert result.endswith("...")
|
||||
|
||||
def test_limit_0_returns_ellipsis(self):
|
||||
"""limit=0 → 0-char slice + "..." since len("hello") > 0."""
|
||||
result = brief_task("hello", limit=0)
|
||||
assert result == "..."
|
||||
|
||||
def test_limit_1_single_char_plus_ellipsis(self):
|
||||
result = brief_task("hello", limit=1)
|
||||
assert len(result) == 4 # 1 char + "..."
|
||||
assert result.startswith("h")
|
||||
assert result.endswith("...")
|
||||
Reference in New Issue
Block a user