From 15bc7ae8337c6591cc238462926a0572379dd3ba Mon Sep 17 00:00:00 2001 From: Anya Date: Sat, 7 Feb 2026 16:52:56 +0000 Subject: [PATCH] test(frontend): add ActivityFeed component tests + expand coverage slice --- frontend/src/app/activity/page.tsx | 29 ++---- .../components/activity/ActivityFeed.test.tsx | 88 +++++++++++++++++++ .../src/components/activity/ActivityFeed.tsx | 47 ++++++++++ frontend/src/lib/backoff.test.ts | 86 +++++++++--------- frontend/vitest.config.ts | 2 +- 5 files changed, 189 insertions(+), 63 deletions(-) create mode 100644 frontend/src/components/activity/ActivityFeed.test.tsx create mode 100644 frontend/src/components/activity/ActivityFeed.tsx diff --git a/frontend/src/app/activity/page.tsx b/frontend/src/app/activity/page.tsx index 73257eb0..72408de7 100644 --- a/frontend/src/app/activity/page.tsx +++ b/frontend/src/app/activity/page.tsx @@ -14,6 +14,7 @@ import { } from "@/api/generated/activity/activity"; import type { ActivityTaskCommentFeedItemRead } from "@/api/generated/model"; import { Markdown } from "@/components/atoms/Markdown"; +import { ActivityFeed } from "@/components/activity/ActivityFeed"; import { DashboardSidebar } from "@/components/organisms/DashboardSidebar"; import { DashboardShell } from "@/components/templates/DashboardShell"; import { Button } from "@/components/ui/button"; @@ -329,28 +330,12 @@ export default function ActivityPage() {
- {feedQuery.isLoading && feedItems.length === 0 ? ( -

Loading feed…

- ) : feedQuery.error ? ( -
- {feedQuery.error.message || "Unable to load feed."} -
- ) : orderedFeed.length === 0 ? ( -
-

- Waiting for new comments… -

-

- When agents post updates, they will show up here. -

-
- ) : ( -
- {orderedFeed.map((item) => ( - - ))} -
- )} + } + />
diff --git a/frontend/src/components/activity/ActivityFeed.test.tsx b/frontend/src/components/activity/ActivityFeed.test.tsx new file mode 100644 index 00000000..239d1399 --- /dev/null +++ b/frontend/src/components/activity/ActivityFeed.test.tsx @@ -0,0 +1,88 @@ +import { render, screen } from "@testing-library/react"; + +import { ActivityFeed } from "./ActivityFeed"; + +type Item = { id: string; label: string }; + +describe("ActivityFeed", () => { + it("renders loading state when loading and no items", () => { + render( + + isLoading={true} + errorMessage={null} + items={[]} + renderItem={(item) =>
{item.label}
} + />, + ); + + expect(screen.getByText("Loading feed…")).toBeInTheDocument(); + }); + + it("renders error state", () => { + render( + + isLoading={false} + errorMessage={"Boom"} + items={[]} + renderItem={(item) =>
{item.label}
} + />, + ); + + expect(screen.getByText("Boom")).toBeInTheDocument(); + }); + + it("renders default error message when errorMessage is empty", () => { + render( + + isLoading={false} + errorMessage={""} + items={[]} + renderItem={(item) =>
{item.label}
} + />, + ); + + expect(screen.getByText("Unable to load feed.")).toBeInTheDocument(); + }); + + it("renders empty state", () => { + render( + + isLoading={false} + errorMessage={null} + items={[]} + renderItem={(item) =>
{item.label}
} + />, + ); + + expect( + screen.getByText("Waiting for new comments…"), + ).toBeInTheDocument(); + expect( + screen.getByText("When agents post updates, they will show up here."), + ).toBeInTheDocument(); + }); + + it("renders items", () => { + const items: Item[] = [ + { id: "1", label: "First" }, + { id: "2", label: "Second" }, + ]; + + render( + + isLoading={false} + errorMessage={null} + items={items} + renderItem={(item) => ( +
+ {item.label} +
+ )} + />, + ); + + expect(screen.getAllByTestId("feed-item")).toHaveLength(2); + expect(screen.getByText("First")).toBeInTheDocument(); + expect(screen.getByText("Second")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/activity/ActivityFeed.tsx b/frontend/src/components/activity/ActivityFeed.tsx new file mode 100644 index 00000000..dd0bf908 --- /dev/null +++ b/frontend/src/components/activity/ActivityFeed.tsx @@ -0,0 +1,47 @@ +import type { ReactNode } from "react"; + +type FeedItem = { + id: string; +}; + +type ActivityFeedProps = { + isLoading: boolean; + errorMessage?: string | null; + items: TItem[]; + renderItem: (item: TItem) => ReactNode; +}; + +export function ActivityFeed({ + isLoading, + errorMessage, + items, + renderItem, +}: ActivityFeedProps) { + if (isLoading && items.length === 0) { + return

Loading feed…

; + } + + const hasError = errorMessage !== null && errorMessage !== undefined; + if (hasError) { + return ( +
+ {errorMessage || "Unable to load feed."} +
+ ); + } + + if (items.length === 0) { + return ( +
+

+ Waiting for new comments… +

+

+ When agents post updates, they will show up here. +

+
+ ); + } + + return
{items.map(renderItem)}
; +} diff --git a/frontend/src/lib/backoff.test.ts b/frontend/src/lib/backoff.test.ts index 9fbba8bc..7248d00b 100644 --- a/frontend/src/lib/backoff.test.ts +++ b/frontend/src/lib/backoff.test.ts @@ -1,61 +1,67 @@ -import { describe, expect, it, vi } from "vitest"; - import { createExponentialBackoff } from "./backoff"; describe("createExponentialBackoff", () => { - it("increments attempt and clamps delay", () => { - vi.spyOn(Math, "random").mockReturnValue(0); + it("uses default options", () => { + const backoff = createExponentialBackoff(); + expect(backoff.attempt()).toBe(0); + const delay = backoff.nextDelayMs(); + expect(delay).toBeGreaterThanOrEqual(50); + expect(backoff.attempt()).toBe(1); + }); + it("clamps invalid base/max and increments attempt", () => { const backoff = createExponentialBackoff({ - baseMs: 100, + baseMs: Number.NaN, + maxMs: Number.POSITIVE_INFINITY, factor: 2, - maxMs: 250, jitter: 0, }); expect(backoff.attempt()).toBe(0); - expect(backoff.nextDelayMs()).toBe(100); + const d1 = backoff.nextDelayMs(); + // baseMs clamps to min 50 + expect(d1).toBe(50); expect(backoff.attempt()).toBe(1); - expect(backoff.nextDelayMs()).toBe(200); - expect(backoff.nextDelayMs()).toBe(250); // capped - }); - it("clamps invalid numeric options and treats negative jitter as zero", () => { - vi.spyOn(Math, "random").mockReturnValue(0.9999); - - // baseMs: NaN should clamp to min (50) - // maxMs: Infinity should clamp to min (= baseMs) - // jitter: negative -> treated as 0 (no extra delay) - const backoff = createExponentialBackoff({ - baseMs: Number.NaN, - maxMs: Number.POSITIVE_INFINITY, - jitter: -1, - }); - - // With maxMs clamped to baseMs, delay will always be baseMs - expect(backoff.nextDelayMs()).toBe(50); - expect(backoff.nextDelayMs()).toBe(50); - }); - - it("reset brings attempt back to zero", () => { - vi.spyOn(Math, "random").mockReturnValue(0); - - const backoff = createExponentialBackoff({ baseMs: 100, jitter: 0 }); - backoff.nextDelayMs(); - expect(backoff.attempt()).toBe(1); + const d2 = backoff.nextDelayMs(); + // maxMs=+Inf is treated as invalid and clamped to baseMs, so it will cap immediately. + expect(d2).toBe(50); + expect(backoff.attempt()).toBe(2); backoff.reset(); expect(backoff.attempt()).toBe(0); }); - it("uses defaults when options are omitted", () => { - vi.spyOn(Math, "random").mockReturnValue(0); + it("applies positive jitter (extra delay only)", () => { + const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.5); - const backoff = createExponentialBackoff(); - expect(backoff.attempt()).toBe(0); + const backoff = createExponentialBackoff({ + baseMs: 1000, + factor: 2, + maxMs: 2000, + jitter: 0.2, + }); - // Default baseMs is 1000 (clamped within bounds), jitter default is 0.2. - // With Math.random=0, delay should be the normalized base (1000). - expect(backoff.nextDelayMs()).toBe(1000); + // attempt=0 => normalized=1000 => delay = 1000 + floor(0.5*0.2*1000)=1100 + expect(backoff.nextDelayMs()).toBe(1100); + + // attempt=1 => raw=2000 capped=2000 => delay=2000 + floor(0.5*0.2*2000)=2200 but clamped to maxMs (2000) + expect(backoff.nextDelayMs()).toBe(2000); + + randomSpy.mockRestore(); + }); + + it("treats negative jitter as zero", () => { + const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.999); + + const backoff = createExponentialBackoff({ + baseMs: 100, + factor: 2, + maxMs: 1000, + jitter: -1, + }); + + expect(backoff.nextDelayMs()).toBe(100); + randomSpy.mockRestore(); }); }); diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index 43c614ec..eb530c52 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -11,7 +11,7 @@ export default defineConfig({ reportsDirectory: "./coverage", // Policy (scoped gate): require 100% coverage on *explicitly listed* unit-testable modules first. // We'll expand this include list as we add more unit/component tests. - include: ["src/lib/backoff.ts"], + include: ["src/lib/backoff.ts", "src/components/activity/ActivityFeed.tsx"], exclude: ["**/*.d.ts", "src/**/__generated__/**", "src/**/generated/**"], thresholds: { lines: 100,