Merge pull request #48 from abhi1693/anya/frontend-coverage-slice-1

Frontend coverage slice 1: ActivityFeed component tests (loading/success/error/empty)
This commit is contained in:
Abhimanyu Saharan
2026-02-08 23:07:44 +05:30
committed by GitHub
6 changed files with 191 additions and 64 deletions

View File

@@ -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";
@@ -331,28 +332,12 @@ export default function ActivityPage() {
</div>
<div className="p-8">
{feedQuery.isLoading && feedItems.length === 0 ? (
<p className="text-sm text-slate-500">Loading feed</p>
) : feedQuery.error ? (
<div className="rounded-lg border border-slate-200 bg-white p-4 text-sm text-slate-700 shadow-sm">
{feedQuery.error.message || "Unable to load feed."}
</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>
)}
<ActivityFeed
isLoading={feedQuery.isLoading}
errorMessage={feedQuery.error?.message}
items={orderedFeed}
renderItem={(item) => <FeedCard key={item.id} item={item} />}
/>
</div>
</main>
</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";
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();
});
});

View File

@@ -20,7 +20,8 @@
],
"paths": {
"@/*": ["./src/*"]
}
},
"types": ["vitest/globals", "@testing-library/jest-dom/vitest"]
},
"include": [
"next-env.d.ts",

View File

@@ -18,7 +18,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,