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:
@@ -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:
|
||||
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
1877
frontend/package-lock.json
generated
1877
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,40 +200,50 @@ export function BanTrendChart({
|
||||
const tickInterval = TICK_INTERVAL[timeRange];
|
||||
|
||||
return (
|
||||
<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 }}>
|
||||
<defs>
|
||||
<linearGradient id="trendAreaFill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={primaryColour} stopOpacity={0.4} />
|
||||
<stop offset="95%" stopColor={primaryColour} stopOpacity={0.05} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={gridColour} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fill: axisColour, fontSize: 11 }}
|
||||
interval={tickInterval}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
allowDecimals={false}
|
||||
tick={{ fill: axisColour, fontSize: 11 }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip content={TrendTooltip} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="count"
|
||||
stroke={primaryColour}
|
||||
strokeWidth={2}
|
||||
fill="url(#trendAreaFill)"
|
||||
dot={false}
|
||||
activeDot={{ r: 4, fill: primaryColour }}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<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 }}>
|
||||
<defs>
|
||||
<linearGradient id="trendAreaFill" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor={primaryColour} stopOpacity={0.4} />
|
||||
<stop offset="95%" stopColor={primaryColour} stopOpacity={0.05} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={gridColour} />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fill: axisColour, fontSize: 11 }}
|
||||
interval={tickInterval}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
allowDecimals={false}
|
||||
tick={{ fill: axisColour, fontSize: 11 }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<Tooltip content={TrendTooltip} />
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="count"
|
||||
stroke={primaryColour}
|
||||
strokeWidth={2}
|
||||
fill="url(#trendAreaFill)"
|
||||
dot={false}
|
||||
activeDot={{ r: 4, fill: primaryColour }}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</ChartStateWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
131
frontend/src/components/ChartStateWrapper.tsx
Normal file
131
frontend/src/components/ChartStateWrapper.tsx
Normal 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}</>;
|
||||
}
|
||||
@@ -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,66 +140,50 @@ 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 (
|
||||
<div className={styles.wrapper} style={{ height: chartHeight }}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart
|
||||
layout="vertical"
|
||||
data={entries}
|
||||
margin={{ top: 4, right: 16, bottom: 4, left: 8 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={gridColour} horizontal={false} />
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={{ fill: axisColour, fontSize: 12 }}
|
||||
axisLine={{ stroke: gridColour }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
width={160}
|
||||
tick={{ fill: axisColour, fontSize: 12 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip content={JailTooltip} cursor={{ fill: "transparent" }} />
|
||||
<Bar dataKey="value" fill={primaryColour} radius={[0, 3, 3, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<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
|
||||
layout="vertical"
|
||||
data={entries}
|
||||
margin={{ top: 4, right: 16, bottom: 4, left: 8 }}
|
||||
>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={gridColour} horizontal={false} />
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={{ fill: axisColour, fontSize: 12 }}
|
||||
axisLine={{ stroke: gridColour }}
|
||||
tickLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="name"
|
||||
width={160}
|
||||
tick={{ fill: axisColour, fontSize: 12 }}
|
||||
axisLine={false}
|
||||
tickLine={false}
|
||||
/>
|
||||
<Tooltip content={JailTooltip} cursor={{ fill: "transparent" }} />
|
||||
<Bar dataKey="value" fill={primaryColour} radius={[0, 3, 3, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</ChartStateWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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] ?? "");
|
||||
|
||||
@@ -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);
|
||||
|
||||
90
frontend/src/components/__tests__/BanTrendChart.test.tsx
Normal file
90
frontend/src/components/__tests__/BanTrendChart.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
123
frontend/src/components/__tests__/ChartStateWrapper.test.tsx
Normal file
123
frontend/src/components/__tests__/ChartStateWrapper.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
33
frontend/src/setupTests.ts
Normal file
33
frontend/src/setupTests.ts
Normal 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
1
frontend/src/vitest.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="@testing-library/jest-dom" />
|
||||
17
frontend/vitest.config.ts
Normal file
17
frontend/vitest.config.ts
Normal 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"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user