Polish dashboard charts and add frontend tests (Stage 6)

Task 6.1 - Consistent loading/error/empty states across all charts:
- Add ChartStateWrapper shared component with Spinner, error MessageBar
  + Retry button, and friendly empty message
- Expose reload() in useBanTrend, useJailDistribution,
  useDashboardCountryData hooks
- Update BanTrendChart and JailDistributionChart to use ChartStateWrapper
- Add empty state to TopCountriesBarChart and TopCountriesPieChart
- Replace manual loading/error logic in DashboardPage with ChartStateWrapper

Task 6.2 - Frontend tests (5 files, 20 tests):
- Install Vitest v4, jsdom, @testing-library/react, @testing-library/jest-dom
- Add vitest.config.ts (separate from vite.config.ts to avoid Vite v5/v7 clash)
- Add src/setupTests.ts with jest-dom matchers and ResizeObserver/matchMedia stubs
- Tests: ChartStateWrapper (7), BanTrendChart (4), JailDistributionChart (4),
  TopCountriesPieChart (2), TopCountriesBarChart (3)

Task 6.3 - Full QA:
- ruff: clean
- mypy --strict: 52 files, no issues
- pytest: 497 passed
- tsc --noEmit: clean
- eslint: clean (added test-file override for explicit-function-return-type)
- vite build: success
This commit is contained in:
2026-03-11 17:25:28 +01:00
parent fe8eefa173
commit 576ec43854
21 changed files with 2628 additions and 167 deletions

View File

@@ -469,7 +469,7 @@ Add the `JailDistributionChart` as a third chart card alongside the two country
### Task 6.1 — Ensure consistent loading, error, and empty states across all charts
**Status:** `not started`
**Status:** `done`
Review all four chart components and ensure:
@@ -489,7 +489,7 @@ Extract a small shared wrapper if three or more charts duplicate the same loadin
### Task 6.2 — Write frontend tests for chart components
**Status:** `not started`
**Status:** `done`
Add tests for each chart component to confirm:
@@ -511,7 +511,7 @@ Follow the project's existing frontend test setup and conventions.
### Task 6.3 — Full build and lint check
**Status:** `not started`
**Status:** `done`
Run the complete quality-assurance pipeline:

View File

@@ -25,4 +25,10 @@ export default tseslint.config(
},
},
prettierConfig,
{
files: ["src/**/*.test.{ts,tsx}", "src/setupTests.ts"],
rules: {
"@typescript-eslint/explicit-function-return-type": "off",
},
},
);

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,9 @@
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"typecheck": "tsc --noEmit",
"format": "prettier --write 'src/**/*.{ts,tsx,css}'"
"format": "prettier --write 'src/**/*.{ts,tsx,css}'",
"test": "vitest run --config vitest.config.ts",
"coverage": "vitest run --config vitest.config.ts --coverage"
},
"dependencies": {
"@fluentui/react-components": "^9.55.0",
@@ -24,19 +26,25 @@
},
"devDependencies": {
"@eslint/js": "^9.13.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^25.3.2",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@typescript-eslint/eslint-plugin": "^8.13.0",
"@typescript-eslint/parser": "^8.13.0",
"@vitejs/plugin-react": "^4.3.3",
"@vitest/coverage-v8": "^4.0.18",
"eslint": "^9.13.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-react-hooks": "^5.0.0",
"jiti": "^2.6.1",
"jsdom": "^28.1.0",
"prettier": "^3.3.3",
"typescript": "^5.6.3",
"typescript-eslint": "^8.56.1",
"vite": "^5.4.11"
"vite": "^5.4.11",
"vitest": "^4.0.18"
}
}

View File

@@ -15,10 +15,6 @@ import {
} from "recharts";
import type { TooltipContentProps } from "recharts/types/component/Tooltip";
import {
MessageBar,
MessageBarBody,
Spinner,
Text,
tokens,
makeStyles,
} from "@fluentui/react-components";
@@ -28,6 +24,7 @@ import {
CHART_PALETTE,
resolveFluentToken,
} from "../utils/chartTheme";
import { ChartStateWrapper } from "./ChartStateWrapper";
import { useBanTrend } from "../hooks/useBanTrend";
import type { BanOriginFilter, TimeRange } from "../types/ban";
@@ -73,19 +70,9 @@ interface TrendEntry {
// ---------------------------------------------------------------------------
const useStyles = makeStyles({
wrapper: {
width: "100%",
minHeight: `${String(MIN_CHART_HEIGHT)}px`,
display: "flex",
alignItems: "center",
justifyContent: "center",
},
chartWrapper: {
width: "100%",
},
emptyText: {
color: tokens.colorNeutralForeground3,
},
});
// ---------------------------------------------------------------------------
@@ -190,6 +177,12 @@ function TrendTooltip(props: TooltipContentProps): React.JSX.Element | null {
* Fetches data via `useBanTrend` and handles loading, error, and empty states
* inline so the parent only needs to pass filter props.
*
/**
* Area chart showing ban counts over time.
*
* Fetches data via `useBanTrend` and delegates loading, error, and empty states
* to `ChartStateWrapper`.
*
* @param props - `timeRange` and `origin` filter props.
*/
export function BanTrendChart({
@@ -197,33 +190,9 @@ export function BanTrendChart({
origin,
}: BanTrendChartProps): React.JSX.Element {
const styles = useStyles();
const { buckets, isLoading, error } = useBanTrend(timeRange, origin);
if (error != null) {
return (
<MessageBar intent="error">
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
);
}
if (isLoading) {
return (
<div className={styles.wrapper}>
<Spinner label="Loading trend data…" />
</div>
);
}
const { buckets, isLoading, error, reload } = useBanTrend(timeRange, origin);
const isEmpty = buckets.every((b) => b.count === 0);
if (isEmpty) {
return (
<div className={styles.wrapper}>
<Text className={styles.emptyText}>No bans in this time range.</Text>
</div>
);
}
const entries = buildEntries(buckets, timeRange);
const primaryColour = resolveFluentToken(CHART_PALETTE[0] ?? "");
const axisColour = resolveFluentToken(CHART_AXIS_TEXT_TOKEN);
@@ -231,6 +200,14 @@ export function BanTrendChart({
const tickInterval = TICK_INTERVAL[timeRange];
return (
<ChartStateWrapper
isLoading={isLoading}
error={error}
onRetry={reload}
isEmpty={isEmpty}
emptyMessage="No bans in this time range."
minHeight={MIN_CHART_HEIGHT}
>
<div className={styles.chartWrapper} style={{ height: MIN_CHART_HEIGHT }}>
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={entries} margin={{ top: 8, right: 16, left: 0, bottom: 0 }}>
@@ -266,5 +243,7 @@ export function BanTrendChart({
</AreaChart>
</ResponsiveContainer>
</div>
</ChartStateWrapper>
);
}

View File

@@ -0,0 +1,131 @@
/**
* ChartStateWrapper — shared wrapper that handles loading, error, and empty
* states for all dashboard chart components.
*
* Renders the chart `children` only when the data is ready and non-empty.
* Otherwise displays the appropriate state UI with consistent styling.
*/
import {
Button,
MessageBar,
MessageBarActions,
MessageBarBody,
Spinner,
Text,
makeStyles,
tokens,
} from "@fluentui/react-components";
import { ArrowClockwiseRegular } from "@fluentui/react-icons";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** Props for {@link ChartStateWrapper}. */
interface ChartStateWrapperProps {
/** True while data is loading. Shows a `<Spinner>` when active. */
isLoading: boolean;
/** Error message string, or `null` when no error has occurred. */
error: string | null;
/**
* Callback invoked when the user clicks the "Retry" button in error state.
* Must trigger a re-fetch in the parent component or hook.
*/
onRetry: () => void;
/** True when data loaded successfully but there are zero records to display. */
isEmpty: boolean;
/** Human-readable message shown in the empty state (default provided). */
emptyMessage?: string;
/** Minimum height in pixels for the state placeholder (default: 200). */
minHeight?: number;
/** The chart content to render when data is ready. */
children: React.ReactNode;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const DEFAULT_MIN_HEIGHT = 200;
const DEFAULT_EMPTY_MESSAGE = "No bans in this time range.";
// ---------------------------------------------------------------------------
// Styles
// ---------------------------------------------------------------------------
const useStyles = makeStyles({
centred: {
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
},
emptyText: {
color: tokens.colorNeutralForeground3,
},
});
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
/**
* Wrap a chart component to provide consistent loading, error, and empty states.
*
* Usage:
* ```tsx
* <ChartStateWrapper isLoading={isLoading} error={error} onRetry={reload} isEmpty={data.length === 0}>
* <MyChart data={data} />
* </ChartStateWrapper>
* ```
*
* @param props - Loading/error/empty state flags and the chart children.
*/
export function ChartStateWrapper({
isLoading,
error,
onRetry,
isEmpty,
emptyMessage = DEFAULT_EMPTY_MESSAGE,
minHeight = DEFAULT_MIN_HEIGHT,
children,
}: ChartStateWrapperProps): React.JSX.Element {
const styles = useStyles();
const placeholderStyle = { minHeight: `${String(minHeight)}px` };
if (error != null) {
return (
<MessageBar intent="error">
<MessageBarBody>{error}</MessageBarBody>
<MessageBarActions>
<Button
icon={<ArrowClockwiseRegular />}
size="small"
onClick={onRetry}
>
Retry
</Button>
</MessageBarActions>
</MessageBar>
);
}
if (isLoading) {
return (
<div className={styles.centred} style={placeholderStyle}>
<Spinner label="Loading chart data…" />
</div>
);
}
if (isEmpty) {
return (
<div className={styles.centred} style={placeholderStyle}>
<Text className={styles.emptyText}>{emptyMessage}</Text>
</div>
);
}
return <>{children}</>;
}

View File

@@ -17,10 +17,6 @@ import {
} from "recharts";
import type { TooltipContentProps } from "recharts/types/component/Tooltip";
import {
MessageBar,
MessageBarBody,
Spinner,
Text,
tokens,
makeStyles,
} from "@fluentui/react-components";
@@ -30,6 +26,7 @@ import {
CHART_PALETTE,
resolveFluentToken,
} from "../utils/chartTheme";
import { ChartStateWrapper } from "./ChartStateWrapper";
import { useJailDistribution } from "../hooks/useJailDistribution";
import type { BanOriginFilter, TimeRange } from "../types/ban";
@@ -77,16 +74,6 @@ const useStyles = makeStyles({
width: "100%",
overflowX: "hidden",
},
stateWrapper: {
width: "100%",
minHeight: `${String(MIN_CHART_HEIGHT)}px`,
display: "flex",
alignItems: "center",
justifyContent: "center",
},
emptyText: {
color: tokens.colorNeutralForeground3,
},
});
// ---------------------------------------------------------------------------
@@ -153,40 +140,23 @@ export function JailDistributionChart({
origin,
}: JailDistributionChartProps): React.JSX.Element {
const styles = useStyles();
const { jails, isLoading, error } = useJailDistribution(timeRange, origin);
if (error != null) {
return (
<MessageBar intent="error">
<MessageBarBody>{error}</MessageBarBody>
</MessageBar>
);
}
if (isLoading) {
return (
<div className={styles.stateWrapper}>
<Spinner label="Loading chart data…" />
</div>
);
}
if (jails.length === 0) {
return (
<div className={styles.stateWrapper}>
<Text className={styles.emptyText}>No ban data for the selected period.</Text>
</div>
);
}
const { jails, isLoading, error, reload } = useJailDistribution(timeRange, origin);
const entries = buildEntries(jails);
const chartHeight = Math.max(entries.length * BAR_HEIGHT_PX, MIN_CHART_HEIGHT);
const primaryColour = resolveFluentToken(CHART_PALETTE[0] ?? "");
const axisColour = resolveFluentToken(CHART_AXIS_TEXT_TOKEN);
const gridColour = resolveFluentToken(CHART_GRID_LINE_TOKEN);
return (
<ChartStateWrapper
isLoading={isLoading}
error={error}
onRetry={reload}
isEmpty={jails.length === 0}
emptyMessage="No bans in this time range."
minHeight={MIN_CHART_HEIGHT}
>
<div className={styles.wrapper} style={{ height: chartHeight }}>
<ResponsiveContainer width="100%" height="100%">
<BarChart
@@ -214,5 +184,6 @@ export function JailDistributionChart({
</BarChart>
</ResponsiveContainer>
</div>
</ChartStateWrapper>
);
}

View File

@@ -13,7 +13,7 @@ import {
YAxis,
} from "recharts";
import type { TooltipContentProps } from "recharts/types/component/Tooltip";
import { tokens, makeStyles } from "@fluentui/react-components";
import { tokens, makeStyles, Text } from "@fluentui/react-components";
import {
CHART_PALETTE,
CHART_AXIS_TEXT_TOKEN,
@@ -65,6 +65,16 @@ const useStyles = makeStyles({
width: "100%",
overflowX: "hidden",
},
emptyWrapper: {
width: "100%",
minHeight: `${String(MIN_CHART_HEIGHT)}px`,
display: "flex",
alignItems: "center",
justifyContent: "center",
},
emptyText: {
color: tokens.colorNeutralForeground3,
},
});
// ---------------------------------------------------------------------------
@@ -140,6 +150,15 @@ export function TopCountriesBarChart({
const styles = useStyles();
const entries = buildEntries(countries, countryNames);
if (entries.length === 0) {
return (
<div className={styles.emptyWrapper}>
<Text className={styles.emptyText}>No bans in this time range.</Text>
</div>
);
}
const chartHeight = Math.max(entries.length * BAR_HEIGHT_PX, MIN_CHART_HEIGHT);
const primaryColour = resolveFluentToken(CHART_PALETTE[0] ?? "");

View File

@@ -12,7 +12,7 @@ import {
} from "recharts";
import type { PieLabelRenderProps } from "recharts";
import type { TooltipContentProps } from "recharts/types/component/Tooltip";
import { tokens, makeStyles } from "@fluentui/react-components";
import { tokens, makeStyles, Text } from "@fluentui/react-components";
import { CHART_PALETTE, resolveFluentToken } from "../utils/chartTheme";
// ---------------------------------------------------------------------------
@@ -49,6 +49,16 @@ const useStyles = makeStyles({
width: "100%",
minHeight: "280px",
},
emptyWrapper: {
width: "100%",
minHeight: "280px",
display: "flex",
alignItems: "center",
justifyContent: "center",
},
emptyText: {
color: tokens.colorNeutralForeground3,
},
});
// ---------------------------------------------------------------------------
@@ -134,6 +144,14 @@ export function TopCountriesPieChart({
const slices = buildSlices(countries, countryNames, resolvedPalette);
const total = slices.reduce((sum, s) => sum + s.value, 0);
if (slices.length === 0) {
return (
<div className={styles.emptyWrapper}>
<Text className={styles.emptyText}>No bans in this time range.</Text>
</div>
);
}
/** Format legend entries as "Country Name (xx%)" */
const legendFormatter = (value: string): string => {
const slice = slices.find((s) => s.name === value);

View File

@@ -0,0 +1,90 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { BanTrendChart } from "../BanTrendChart";
import * as useBanTrendModule from "../../hooks/useBanTrend";
import type { UseBanTrendResult } from "../../hooks/useBanTrend";
import type { BanTrendBucket } from "../../types/ban";
vi.mock("recharts", () => ({
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
AreaChart: ({ children }: { children: React.ReactNode }) => (
<div data-testid="area-chart">{children}</div>
),
Area: () => null,
CartesianGrid: () => null,
XAxis: () => null,
YAxis: () => null,
Tooltip: () => null,
defs: () => null,
linearGradient: () => null,
stop: () => null,
}));
vi.mock("../../hooks/useBanTrend");
function wrap(ui: React.ReactElement) {
return render(
<FluentProvider theme={webLightTheme}>{ui}</FluentProvider>,
);
}
const defaultResult: UseBanTrendResult = {
buckets: [],
bucketSize: "1h",
isLoading: false,
error: null,
reload: vi.fn(),
};
function mockHook(overrides: Partial<UseBanTrendResult>) {
vi.mocked(useBanTrendModule.useBanTrend).mockReturnValue({
...defaultResult,
...overrides,
});
}
beforeEach(() => {
vi.mocked(useBanTrendModule.useBanTrend).mockReturnValue(defaultResult);
});
describe("BanTrendChart", () => {
it("shows a spinner while loading", () => {
mockHook({ isLoading: true });
wrap(<BanTrendChart timeRange="24h" origin="all" />);
expect(screen.getByRole("progressbar")).toBeInTheDocument();
});
it("shows error message and retry button on error", async () => {
const reload = vi.fn();
mockHook({ error: "Failed to fetch trend", reload });
const user = userEvent.setup();
wrap(<BanTrendChart timeRange="24h" origin="all" />);
expect(screen.getByText("Failed to fetch trend")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /retry/i }));
expect(reload).toHaveBeenCalledOnce();
});
it("shows empty state when all buckets have zero count", () => {
const emptyBuckets: BanTrendBucket[] = [
{ timestamp: "2024-01-01T00:00:00Z", count: 0 },
{ timestamp: "2024-01-01T01:00:00Z", count: 0 },
];
mockHook({ buckets: emptyBuckets });
wrap(<BanTrendChart timeRange="24h" origin="all" />);
expect(screen.getByText("No bans in this time range.")).toBeInTheDocument();
});
it("renders chart when data is present", () => {
const buckets: BanTrendBucket[] = [
{ timestamp: "2024-01-01T00:00:00Z", count: 5 },
{ timestamp: "2024-01-01T01:00:00Z", count: 12 },
];
mockHook({ buckets });
wrap(<BanTrendChart timeRange="24h" origin="all" />);
expect(screen.getByTestId("area-chart")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,123 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { ChartStateWrapper } from "../ChartStateWrapper";
function wrap(ui: React.ReactElement) {
return render(
<FluentProvider theme={webLightTheme}>{ui}</FluentProvider>,
);
}
describe("ChartStateWrapper", () => {
it("renders a spinner when isLoading is true", () => {
wrap(
<ChartStateWrapper
isLoading={true}
error={null}
onRetry={vi.fn()}
isEmpty={false}
>
<div>chart</div>
</ChartStateWrapper>,
);
expect(screen.getByRole("progressbar")).toBeInTheDocument();
expect(screen.queryByText("chart")).not.toBeInTheDocument();
});
it("renders an error bar with retry button when error is set", () => {
const onRetry = vi.fn();
wrap(
<ChartStateWrapper
isLoading={false}
error="Network error"
onRetry={onRetry}
isEmpty={false}
>
<div>chart</div>
</ChartStateWrapper>,
);
expect(screen.getByText("Network error")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
expect(screen.queryByText("chart")).not.toBeInTheDocument();
});
it("calls onRetry when Retry button is clicked", async () => {
const onRetry = vi.fn();
const user = userEvent.setup();
wrap(
<ChartStateWrapper
isLoading={false}
error="Oops"
onRetry={onRetry}
isEmpty={false}
>
<div>chart</div>
</ChartStateWrapper>,
);
await user.click(screen.getByRole("button", { name: /retry/i }));
expect(onRetry).toHaveBeenCalledOnce();
});
it("renders the empty message when isEmpty is true", () => {
wrap(
<ChartStateWrapper
isLoading={false}
error={null}
onRetry={vi.fn()}
isEmpty={true}
emptyMessage="Nothing here."
>
<div>chart</div>
</ChartStateWrapper>,
);
expect(screen.getByText("Nothing here.")).toBeInTheDocument();
expect(screen.queryByText("chart")).not.toBeInTheDocument();
});
it("renders children when not loading, no error, and not empty", () => {
wrap(
<ChartStateWrapper
isLoading={false}
error={null}
onRetry={vi.fn()}
isEmpty={false}
>
<div>chart content</div>
</ChartStateWrapper>,
);
expect(screen.getByText("chart content")).toBeInTheDocument();
});
it("prioritises error state over loading state", () => {
wrap(
<ChartStateWrapper
isLoading={true}
error="Some error"
onRetry={vi.fn()}
isEmpty={false}
>
<div>chart</div>
</ChartStateWrapper>,
);
expect(screen.getByText("Some error")).toBeInTheDocument();
expect(screen.queryByRole("progressbar")).not.toBeInTheDocument();
});
it("uses default empty message when none is provided", () => {
wrap(
<ChartStateWrapper
isLoading={false}
error={null}
onRetry={vi.fn()}
isEmpty={true}
>
<div>chart</div>
</ChartStateWrapper>,
);
expect(
screen.getByText("No bans in this time range."),
).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,87 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { JailDistributionChart } from "../JailDistributionChart";
import * as useJailDistributionModule from "../../hooks/useJailDistribution";
import type { UseJailDistributionResult } from "../../hooks/useJailDistribution";
vi.mock("recharts", () => ({
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
BarChart: ({ children }: { children: React.ReactNode }) => (
<div data-testid="bar-chart">{children}</div>
),
Bar: () => null,
CartesianGrid: () => null,
XAxis: () => null,
YAxis: () => null,
Tooltip: () => null,
Cell: () => null,
}));
vi.mock("../../hooks/useJailDistribution");
function wrap(ui: React.ReactElement) {
return render(
<FluentProvider theme={webLightTheme}>{ui}</FluentProvider>,
);
}
const defaultResult: UseJailDistributionResult = {
jails: [],
total: 0,
isLoading: false,
error: null,
reload: vi.fn(),
};
function mockHook(overrides: Partial<UseJailDistributionResult>) {
vi.mocked(useJailDistributionModule.useJailDistribution).mockReturnValue({
...defaultResult,
...overrides,
});
}
beforeEach(() => {
vi.mocked(useJailDistributionModule.useJailDistribution).mockReturnValue(
defaultResult,
);
});
describe("JailDistributionChart", () => {
it("shows a spinner while loading", () => {
mockHook({ isLoading: true });
wrap(<JailDistributionChart timeRange="24h" origin="all" />);
expect(screen.getByRole("progressbar")).toBeInTheDocument();
});
it("shows error message and retry button on error", async () => {
const reload = vi.fn();
mockHook({ error: "Request failed", reload });
const user = userEvent.setup();
wrap(<JailDistributionChart timeRange="24h" origin="all" />);
expect(screen.getByText("Request failed")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /retry/i }));
expect(reload).toHaveBeenCalledOnce();
});
it("shows empty state when jails array is empty", () => {
mockHook({ jails: [], total: 0 });
wrap(<JailDistributionChart timeRange="24h" origin="all" />);
expect(screen.getByText(/no bans/i)).toBeInTheDocument();
});
it("renders chart when jail data is present", () => {
mockHook({
jails: [
{ jail: "sshd", count: 42 },
{ jail: "nginx-http-auth", count: 7 },
],
total: 49,
});
wrap(<JailDistributionChart timeRange="24h" origin="all" />);
expect(screen.getByTestId("bar-chart")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,57 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { TopCountriesBarChart } from "../TopCountriesBarChart";
vi.mock("recharts", () => ({
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
BarChart: ({ children }: { children: React.ReactNode }) => (
<div data-testid="bar-chart">{children}</div>
),
Bar: () => null,
CartesianGrid: () => null,
XAxis: () => null,
YAxis: () => null,
Tooltip: () => null,
Cell: () => null,
}));
function wrap(ui: React.ReactElement) {
return render(
<FluentProvider theme={webLightTheme}>{ui}</FluentProvider>,
);
}
describe("TopCountriesBarChart", () => {
it("shows empty state when countries is empty", () => {
wrap(<TopCountriesBarChart countries={{}} countryNames={{}} />);
expect(screen.getByText("No bans in this time range.")).toBeInTheDocument();
expect(screen.queryByTestId("bar-chart")).not.toBeInTheDocument();
});
it("renders bar chart when country data is provided", () => {
wrap(
<TopCountriesBarChart
countries={{ DE: 50, US: 30, FR: 15 }}
countryNames={{ DE: "Germany", US: "United States", FR: "France" }}
/>,
);
expect(screen.getByTestId("bar-chart")).toBeInTheDocument();
});
it("does not render more than 20 bars (TOP_N limit)", () => {
// Build 30 countries — only top 20 should appear in the chart
const countries: Record<string, number> = {};
const countryNames: Record<string, string> = {};
for (let i = 0; i < 30; i++) {
const code = `C${String(i).padStart(2, "0")}`;
countries[code] = 30 - i;
countryNames[code] = `Country ${String(i)}`;
}
wrap(<TopCountriesBarChart countries={countries} countryNames={countryNames} />);
// Chart should render (not show empty state) with data present
expect(screen.getByTestId("bar-chart")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,41 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import { TopCountriesPieChart } from "../TopCountriesPieChart";
vi.mock("recharts", () => ({
ResponsiveContainer: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
PieChart: ({ children }: { children: React.ReactNode }) => (
<div data-testid="pie-chart">{children}</div>
),
Pie: () => null,
Cell: () => null,
Tooltip: () => null,
Legend: () => null,
}));
function wrap(ui: React.ReactElement) {
return render(
<FluentProvider theme={webLightTheme}>{ui}</FluentProvider>,
);
}
describe("TopCountriesPieChart", () => {
it("shows empty state when countries is empty", () => {
wrap(<TopCountriesPieChart countries={{}} countryNames={{}} />);
expect(screen.getByText("No bans in this time range.")).toBeInTheDocument();
expect(screen.queryByTestId("pie-chart")).not.toBeInTheDocument();
});
it("renders pie chart when country data is provided", () => {
wrap(
<TopCountriesPieChart
countries={{ DE: 30, US: 20, CN: 10 }}
countryNames={{ DE: "Germany", US: "United States", CN: "China" }}
/>,
);
expect(screen.getByTestId("pie-chart")).toBeInTheDocument();
});
});

View File

@@ -23,6 +23,8 @@ export interface UseBanTrendResult {
isLoading: boolean;
/** Error message or `null`. */
error: string | null;
/** Re-fetch the data immediately. */
reload: () => void;
}
// ---------------------------------------------------------------------------
@@ -79,5 +81,5 @@ export function useBanTrend(
};
}, [load]);
return { buckets, bucketSize, isLoading, error };
return { buckets, bucketSize, isLoading, error, reload: load };
}

View File

@@ -29,6 +29,8 @@ export interface UseDashboardCountryDataResult {
isLoading: boolean;
/** Error message or `null`. */
error: string | null;
/** Re-fetch the data immediately. */
reload: () => void;
}
// ---------------------------------------------------------------------------
@@ -91,5 +93,5 @@ export function useDashboardCountryData(
};
}, [load]);
return { countries, countryNames, bans, total, isLoading, error };
return { countries, countryNames, bans, total, isLoading, error, reload: load };
}

View File

@@ -23,6 +23,8 @@ export interface UseJailDistributionResult {
isLoading: boolean;
/** Error message or `null`. */
error: string | null;
/** Re-fetch the data immediately. */
reload: () => void;
}
// ---------------------------------------------------------------------------
@@ -81,5 +83,5 @@ export function useJailDistribution(
};
}, [load]);
return { jails, total, isLoading, error };
return { jails, total, isLoading, error, reload: load };
}

View File

@@ -8,15 +8,13 @@
import { useState } from "react";
import {
MessageBar,
MessageBarBody,
Spinner,
Text,
ToggleButton,
Toolbar,
makeStyles,
tokens,
} from "@fluentui/react-components";
import { ChartStateWrapper } from "../components/ChartStateWrapper";
import { BanTable } from "../components/BanTable";
import { BanTrendChart } from "../components/BanTrendChart";
import { JailDistributionChart } from "../components/JailDistributionChart";
@@ -109,7 +107,7 @@ export function DashboardPage(): React.JSX.Element {
const [timeRange, setTimeRange] = useState<TimeRange>("24h");
const [originFilter, setOriginFilter] = useState<BanOriginFilter>("all");
const { countries, countryNames, isLoading: countryLoading, error: countryError } =
const { countries, countryNames, isLoading: countryLoading, error: countryError, reload: reloadCountry } =
useDashboardCountryData(timeRange, originFilter);
return (
@@ -143,27 +141,28 @@ export function DashboardPage(): React.JSX.Element {
</Text>
</div>
<div className={styles.tabContent}>
{countryError != null && (
<MessageBar intent="error">
<MessageBarBody>{countryError}</MessageBarBody>
</MessageBar>
)}
{countryLoading && countryError == null ? (
<Spinner label="Loading chart data…" />
) : (
<ChartStateWrapper
isLoading={countryLoading}
error={countryError}
onRetry={reloadCountry}
isEmpty={!countryLoading && Object.keys(countries).length === 0}
emptyMessage="No ban data for the selected period."
>
<div className={styles.chartsRow}>
<div className={styles.chartCard}>
<TopCountriesPieChart
countries={countries}
countryNames={countryNames}
/>
</div> <div className={styles.chartCard}>
</div>
<div className={styles.chartCard}>
<TopCountriesBarChart
countries={countries}
countryNames={countryNames}
/>
</div> </div>
)}
</div>
</div>
</ChartStateWrapper>
</div>
</div>

View File

@@ -0,0 +1,33 @@
import { expect, afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
import * as jestDomMatchers from "@testing-library/jest-dom/matchers";
// Extend Vitest's expect with jest-dom matchers (toBeInTheDocument, etc.)
expect.extend(jestDomMatchers);
// Ensure React Testing Library cleans up after every test
afterEach(cleanup);
// Recharts and Fluent UI rely on ResizeObserver which jsdom does not provide.
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
globalThis.ResizeObserver = ResizeObserverStub;
// Fluent UI animations rely on matchMedia.
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});

1
frontend/src/vitest.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="@testing-library/jest-dom" />

17
frontend/vitest.config.ts Normal file
View File

@@ -0,0 +1,17 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import { resolve } from "path";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": resolve(__dirname, "src"),
},
},
test: {
environment: "jsdom",
globals: false,
setupFiles: ["./src/setupTests.ts"],
},
});