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";
|
} 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";
|
||||||
@@ -331,28 +332,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>
|
||||||
|
|||||||
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";
|
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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -20,7 +20,8 @@
|
|||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
},
|
||||||
|
"types": ["vitest/globals", "@testing-library/jest-dom/vitest"]
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"next-env.d.ts",
|
"next-env.d.ts",
|
||||||
|
|||||||
@@ -18,7 +18,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,
|
||||||
|
|||||||
Reference in New Issue
Block a user