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:
@@ -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>
|
||||
|
||||
88
frontend/src/components/activity/ActivityFeed.test.tsx
Normal file
88
frontend/src/components/activity/ActivityFeed.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
47
frontend/src/components/activity/ActivityFeed.tsx
Normal file
47
frontend/src/components/activity/ActivityFeed.tsx
Normal 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>;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"types": ["vitest/globals", "@testing-library/jest-dom/vitest"]
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user