test(frontend): add ActivityFeed component tests + expand coverage slice

This commit is contained in:
Anya
2026-02-07 16:52:56 +00:00
parent 64581f4534
commit 15bc7ae833
5 changed files with 189 additions and 63 deletions

View File

@@ -14,6 +14,7 @@ import {
} from "@/api/generated/activity/activity"; } from "@/api/generated/activity/activity";
import type { ActivityTaskCommentFeedItemRead } from "@/api/generated/model"; import type { ActivityTaskCommentFeedItemRead } from "@/api/generated/model";
import { Markdown } from "@/components/atoms/Markdown"; import { Markdown } from "@/components/atoms/Markdown";
import { ActivityFeed } from "@/components/activity/ActivityFeed";
import { DashboardSidebar } from "@/components/organisms/DashboardSidebar"; import { DashboardSidebar } from "@/components/organisms/DashboardSidebar";
import { DashboardShell } from "@/components/templates/DashboardShell"; import { DashboardShell } from "@/components/templates/DashboardShell";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -329,28 +330,12 @@ export default function ActivityPage() {
</div> </div>
<div className="p-8"> <div className="p-8">
{feedQuery.isLoading && feedItems.length === 0 ? ( <ActivityFeed
<p className="text-sm text-slate-500">Loading feed</p> isLoading={feedQuery.isLoading}
) : feedQuery.error ? ( errorMessage={feedQuery.error?.message}
<div className="rounded-lg border border-slate-200 bg-white p-4 text-sm text-slate-700 shadow-sm"> items={orderedFeed}
{feedQuery.error.message || "Unable to load feed."} renderItem={(item) => <FeedCard key={item.id} item={item} />}
</div> />
) : orderedFeed.length === 0 ? (
<div className="rounded-xl border border-slate-200 bg-white p-10 text-center shadow-sm">
<p className="text-sm font-medium text-slate-900">
Waiting for new comments
</p>
<p className="mt-1 text-sm text-slate-500">
When agents post updates, they will show up here.
</p>
</div>
) : (
<div className="space-y-4">
{orderedFeed.map((item) => (
<FeedCard key={item.id} item={item} />
))}
</div>
)}
</div> </div>
</main> </main>
</SignedIn> </SignedIn>

View File

@@ -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(
<ActivityFeed<Item>
isLoading={true}
errorMessage={null}
items={[]}
renderItem={(item) => <div key={item.id}>{item.label}</div>}
/>,
);
expect(screen.getByText("Loading feed…")).toBeInTheDocument();
});
it("renders error state", () => {
render(
<ActivityFeed<Item>
isLoading={false}
errorMessage={"Boom"}
items={[]}
renderItem={(item) => <div key={item.id}>{item.label}</div>}
/>,
);
expect(screen.getByText("Boom")).toBeInTheDocument();
});
it("renders default error message when errorMessage is empty", () => {
render(
<ActivityFeed<Item>
isLoading={false}
errorMessage={""}
items={[]}
renderItem={(item) => <div key={item.id}>{item.label}</div>}
/>,
);
expect(screen.getByText("Unable to load feed.")).toBeInTheDocument();
});
it("renders empty state", () => {
render(
<ActivityFeed<Item>
isLoading={false}
errorMessage={null}
items={[]}
renderItem={(item) => <div key={item.id}>{item.label}</div>}
/>,
);
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(
<ActivityFeed<Item>
isLoading={false}
errorMessage={null}
items={items}
renderItem={(item) => (
<div key={item.id} data-testid="feed-item">
{item.label}
</div>
)}
/>,
);
expect(screen.getAllByTestId("feed-item")).toHaveLength(2);
expect(screen.getByText("First")).toBeInTheDocument();
expect(screen.getByText("Second")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,47 @@
import type { ReactNode } from "react";
type FeedItem = {
id: string;
};
type ActivityFeedProps<TItem extends FeedItem> = {
isLoading: boolean;
errorMessage?: string | null;
items: TItem[];
renderItem: (item: TItem) => ReactNode;
};
export function ActivityFeed<TItem extends FeedItem>({
isLoading,
errorMessage,
items,
renderItem,
}: ActivityFeedProps<TItem>) {
if (isLoading && items.length === 0) {
return <p className="text-sm text-slate-500">Loading feed</p>;
}
const hasError = errorMessage !== null && errorMessage !== undefined;
if (hasError) {
return (
<div className="rounded-lg border border-slate-200 bg-white p-4 text-sm text-slate-700 shadow-sm">
{errorMessage || "Unable to load feed."}
</div>
);
}
if (items.length === 0) {
return (
<div className="rounded-xl border border-slate-200 bg-white p-10 text-center shadow-sm">
<p className="text-sm font-medium text-slate-900">
Waiting for new comments
</p>
<p className="mt-1 text-sm text-slate-500">
When agents post updates, they will show up here.
</p>
</div>
);
}
return <div className="space-y-4">{items.map(renderItem)}</div>;
}

View File

@@ -1,61 +1,67 @@
import { describe, expect, it, vi } from "vitest";
import { createExponentialBackoff } from "./backoff"; import { createExponentialBackoff } from "./backoff";
describe("createExponentialBackoff", () => { describe("createExponentialBackoff", () => {
it("increments attempt and clamps delay", () => { it("uses default options", () => {
vi.spyOn(Math, "random").mockReturnValue(0); 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({ const backoff = createExponentialBackoff({
baseMs: 100, baseMs: Number.NaN,
maxMs: Number.POSITIVE_INFINITY,
factor: 2, factor: 2,
maxMs: 250,
jitter: 0, jitter: 0,
}); });
expect(backoff.attempt()).toBe(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.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", () => { const d2 = backoff.nextDelayMs();
vi.spyOn(Math, "random").mockReturnValue(0.9999); // maxMs=+Inf is treated as invalid and clamped to baseMs, so it will cap immediately.
expect(d2).toBe(50);
// baseMs: NaN should clamp to min (50) expect(backoff.attempt()).toBe(2);
// 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);
backoff.reset(); backoff.reset();
expect(backoff.attempt()).toBe(0); expect(backoff.attempt()).toBe(0);
}); });
it("uses defaults when options are omitted", () => { it("applies positive jitter (extra delay only)", () => {
vi.spyOn(Math, "random").mockReturnValue(0); const randomSpy = vi.spyOn(Math, "random").mockReturnValue(0.5);
const backoff = createExponentialBackoff(); const backoff = createExponentialBackoff({
expect(backoff.attempt()).toBe(0); baseMs: 1000,
factor: 2,
maxMs: 2000,
jitter: 0.2,
});
// Default baseMs is 1000 (clamped within bounds), jitter default is 0.2. // attempt=0 => normalized=1000 => delay = 1000 + floor(0.5*0.2*1000)=1100
// With Math.random=0, delay should be the normalized base (1000). expect(backoff.nextDelayMs()).toBe(1100);
expect(backoff.nextDelayMs()).toBe(1000);
// 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();
}); });
}); });

View File

@@ -11,7 +11,7 @@ export default defineConfig({
reportsDirectory: "./coverage", reportsDirectory: "./coverage",
// Policy (scoped gate): require 100% coverage on *explicitly listed* unit-testable modules first. // 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. // 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/**"], exclude: ["**/*.d.ts", "src/**/__generated__/**", "src/**/generated/**"],
thresholds: { thresholds: {
lines: 100, lines: 100,