feat(service-storage-harness): slice 2 — qbit widgets + LineSeriesChart extract
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
|
||||
export interface SeriesPoint {
|
||||
t: number;
|
||||
v: number | null;
|
||||
}
|
||||
|
||||
export interface ChartSeries {
|
||||
label: string;
|
||||
points: SeriesPoint[];
|
||||
}
|
||||
|
||||
/** Merge multiple time-series into a single recharts-friendly array. */
|
||||
function mergeSeries(series: ChartSeries[]): Record<string, unknown>[] {
|
||||
const map = new Map<number, Record<string, unknown>>();
|
||||
for (const s of series) {
|
||||
for (const p of s.points) {
|
||||
const existing = map.get(p.t) ?? { time: p.t };
|
||||
existing[s.label] = p.v;
|
||||
map.set(p.t, existing);
|
||||
}
|
||||
}
|
||||
return [...map.values()].sort(
|
||||
(a, b) => (a.time as number) - (b.time as number),
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(ms: number): string {
|
||||
return new Date(ms).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
const CHART_COLORS = [
|
||||
"var(--color-chart-1)",
|
||||
"var(--color-chart-2)",
|
||||
"var(--color-chart-3)",
|
||||
"var(--color-chart-4)",
|
||||
"var(--color-chart-5)",
|
||||
];
|
||||
|
||||
interface LineSeriesChartProps {
|
||||
series: ChartSeries[];
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/** Shared recharts line-chart renderer used by PrometheusChart + qBit speed widgets. */
|
||||
export function LineSeriesChart({
|
||||
series,
|
||||
height = 300,
|
||||
}: LineSeriesChartProps) {
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={height}>
|
||||
<LineChart data={mergeSeries(series)}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tickFormatter={formatTime}
|
||||
tick={{ fontSize: 11 }}
|
||||
className="fill-muted-foreground"
|
||||
/>
|
||||
<YAxis tick={{ fontSize: 11 }} className="fill-muted-foreground" />
|
||||
<Tooltip
|
||||
labelFormatter={(label) => formatTime(Number(label))}
|
||||
contentStyle={{
|
||||
backgroundColor: "hsl(var(--popover))",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
borderRadius: "0.5rem",
|
||||
color: "hsl(var(--popover-foreground))",
|
||||
}}
|
||||
/>
|
||||
{series.map((s, i) => (
|
||||
<Line
|
||||
key={s.label}
|
||||
type="monotone"
|
||||
dataKey={s.label}
|
||||
stroke={CHART_COLORS[i % CHART_COLORS.length]}
|
||||
dot={false}
|
||||
strokeWidth={2}
|
||||
connectNulls
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render } from "@testing-library/react";
|
||||
import { LineSeriesChart } from "../LineSeriesChart";
|
||||
import type { ChartSeries } from "../LineSeriesChart";
|
||||
|
||||
describe("LineSeriesChart", () => {
|
||||
it("renders without crashing with series data", () => {
|
||||
const series: ChartSeries[] = [
|
||||
{
|
||||
label: "cpu",
|
||||
points: [
|
||||
{ t: 1000, v: 0.5 },
|
||||
{ t: 2000, v: 0.8 },
|
||||
],
|
||||
},
|
||||
];
|
||||
const { container } = render(<LineSeriesChart series={series} />);
|
||||
// ResponsiveContainer renders a wrapper div even in jsdom
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders without crashing with empty series", () => {
|
||||
const { container } = render(<LineSeriesChart series={[]} />);
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders with custom height", () => {
|
||||
const series: ChartSeries[] = [{ label: "dl", points: [{ t: 1, v: 1 }] }];
|
||||
const { container } = render(
|
||||
<LineSeriesChart series={series} height={200} />,
|
||||
);
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,7 @@ describe("service registry", () => {
|
||||
"jellyfin",
|
||||
"nextcloud",
|
||||
"prometheus",
|
||||
"qbittorrent",
|
||||
"ssh_tasks",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -7,6 +7,9 @@ import { PrometheusMeanWidget } from "../widgets/PrometheusMeanWidget";
|
||||
import { JellyfinWidget } from "../widgets/JellyfinWidget";
|
||||
import { JellyfinNowPlayingWidget } from "../widgets/JellyfinNowPlayingWidget";
|
||||
import { PrometheusMetricWidget } from "../widgets/PrometheusMetricWidget";
|
||||
import { QbittorrentActiveTorrentsWidget } from "../widgets/QbittorrentActiveTorrentsWidget";
|
||||
import { QbittorrentSpeedWidget } from "../widgets/QbittorrentSpeedWidget";
|
||||
import { QbittorrentTotalsWidget } from "../widgets/QbittorrentTotalsWidget";
|
||||
import { SshTaskWidget } from "../widgets/SshTaskWidget";
|
||||
import { StaticWidget } from "../widgets/StaticWidget";
|
||||
import type {
|
||||
@@ -157,6 +160,40 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
|
||||
},
|
||||
],
|
||||
},
|
||||
qbittorrent: {
|
||||
serviceType: "qbittorrent",
|
||||
name: "qBittorrent",
|
||||
description: "Torrent client activity, speeds, and item counts.",
|
||||
widgets: [
|
||||
{
|
||||
kind: "totals",
|
||||
name: "Totals",
|
||||
description: "Count of all listed torrents, broken down by state.",
|
||||
refreshIntervalMs: 30_000,
|
||||
defaultConfig: {},
|
||||
configSchema: { type: "object", properties: {}, required: [] },
|
||||
component: QbittorrentTotalsWidget,
|
||||
},
|
||||
{
|
||||
kind: "active",
|
||||
name: "Active torrents",
|
||||
description: "Torrents currently downloading or uploading.",
|
||||
refreshIntervalMs: 15_000,
|
||||
defaultConfig: {},
|
||||
configSchema: { type: "object", properties: {}, required: [] },
|
||||
component: QbittorrentActiveTorrentsWidget,
|
||||
},
|
||||
{
|
||||
kind: "speed",
|
||||
name: "Speed chart",
|
||||
description: "Live download/upload speed over a short window.",
|
||||
refreshIntervalMs: 5_000,
|
||||
defaultConfig: {},
|
||||
configSchema: { type: "object", properties: {}, required: [] },
|
||||
component: QbittorrentSpeedWidget,
|
||||
},
|
||||
],
|
||||
},
|
||||
jellyfin: {
|
||||
serviceType: "jellyfin",
|
||||
name: "Jellyfin",
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { LineSeriesChart } from "../components/LineSeriesChart";
|
||||
import type { ChartSeries } from "../components/LineSeriesChart";
|
||||
import { SectionCard } from "../components/SectionCard";
|
||||
import { useWidgetData } from "../hooks/useWidgets";
|
||||
import type { WidgetInstance } from "../types";
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
|
||||
interface Props {
|
||||
widget: WidgetInstance;
|
||||
@@ -19,46 +12,6 @@ interface Props {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface SeriesPoint {
|
||||
t: number;
|
||||
v: number | null;
|
||||
}
|
||||
|
||||
interface ChartSeries {
|
||||
label: string;
|
||||
points: SeriesPoint[];
|
||||
}
|
||||
|
||||
/** Merge multiple time-series into a single recharts-friendly array. */
|
||||
function mergeSeries(series: ChartSeries[]): Record<string, unknown>[] {
|
||||
const map = new Map<number, Record<string, unknown>>();
|
||||
for (const s of series) {
|
||||
for (const p of s.points) {
|
||||
const existing = map.get(p.t) ?? { time: p.t };
|
||||
existing[s.label] = p.v;
|
||||
map.set(p.t, existing);
|
||||
}
|
||||
}
|
||||
return [...map.values()].sort(
|
||||
(a, b) => (a.time as number) - (b.time as number),
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(ms: number): string {
|
||||
return new Date(ms).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
const CHART_COLORS = [
|
||||
"var(--color-chart-1)",
|
||||
"var(--color-chart-2)",
|
||||
"var(--color-chart-3)",
|
||||
"var(--color-chart-4)",
|
||||
"var(--color-chart-5)",
|
||||
];
|
||||
|
||||
export function PrometheusChartWidget({
|
||||
widget,
|
||||
refreshIntervalMs,
|
||||
@@ -76,38 +29,7 @@ export function PrometheusChartWidget({
|
||||
<AlertDescription>{data.error}</AlertDescription>
|
||||
</Alert>
|
||||
) : series && series.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={mergeSeries(series)}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tickFormatter={formatTime}
|
||||
tick={{ fontSize: 11 }}
|
||||
className="fill-muted-foreground"
|
||||
/>
|
||||
<YAxis tick={{ fontSize: 11 }} className="fill-muted-foreground" />
|
||||
<Tooltip
|
||||
labelFormatter={(label) => formatTime(Number(label))}
|
||||
contentStyle={{
|
||||
backgroundColor: "hsl(var(--popover))",
|
||||
border: "1px solid hsl(var(--border))",
|
||||
borderRadius: "0.5rem",
|
||||
color: "hsl(var(--popover-foreground))",
|
||||
}}
|
||||
/>
|
||||
{series.map((s, i) => (
|
||||
<Line
|
||||
key={s.label}
|
||||
type="monotone"
|
||||
dataKey={s.label}
|
||||
stroke={CHART_COLORS[i % CHART_COLORS.length]}
|
||||
dot={false}
|
||||
strokeWidth={2}
|
||||
connectNulls
|
||||
/>
|
||||
))}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
<LineSeriesChart series={series} />
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SectionCard } from "../components/SectionCard";
|
||||
import { useWidgetData } from "../hooks/useWidgets";
|
||||
import type { WidgetInstance } from "../types";
|
||||
|
||||
interface Props {
|
||||
widget: WidgetInstance;
|
||||
refreshIntervalMs: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface ActiveTorrent {
|
||||
name: string | null;
|
||||
state: string | null;
|
||||
size: number | null;
|
||||
progress: number | null;
|
||||
dl_speed: number | null;
|
||||
up_speed: number | null;
|
||||
}
|
||||
|
||||
function formatSpeed(bytesPerSec: number | null): string {
|
||||
if (bytesPerSec === null || bytesPerSec <= 0) return "—";
|
||||
const mb = bytesPerSec / 1_000_000;
|
||||
if (mb >= 1) return `${mb.toFixed(1)} MB/s`;
|
||||
return `${(bytesPerSec / 1000).toFixed(0)} KB/s`;
|
||||
}
|
||||
|
||||
export function QbittorrentActiveTorrentsWidget({
|
||||
widget,
|
||||
refreshIntervalMs,
|
||||
description,
|
||||
}: Props) {
|
||||
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
|
||||
const payload = data?.data as { torrents?: ActiveTorrent[] } | undefined;
|
||||
const torrents = payload?.torrents ?? [];
|
||||
|
||||
return (
|
||||
<SectionCard title={widget.title} description={description}>
|
||||
{isLoading && !data ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-8 w-full" />
|
||||
</div>
|
||||
) : data?.error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{data.error}</AlertDescription>
|
||||
</Alert>
|
||||
) : torrents.length > 0 ? (
|
||||
<ul className="max-h-80 space-y-1.5 overflow-y-auto">
|
||||
{torrents.map((t, i) => (
|
||||
<li
|
||||
key={`${t.name}-${i}`}
|
||||
className="flex items-center justify-between gap-2 rounded-md border px-2 py-1 text-sm"
|
||||
>
|
||||
<span className="truncate">{t.name ?? "Unknown"}</span>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
↓{formatSpeed(t.dl_speed)} ↑{formatSpeed(t.up_speed)}
|
||||
</span>
|
||||
<Badge
|
||||
variant={t.state === "downloading" ? "default" : "secondary"}
|
||||
>
|
||||
{t.state ?? "?"}
|
||||
</Badge>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground">No active torrents</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { LineSeriesChart } from "../components/LineSeriesChart";
|
||||
import type { ChartSeries } from "../components/LineSeriesChart";
|
||||
import { SectionCard } from "../components/SectionCard";
|
||||
import { useWidgetData } from "../hooks/useWidgets";
|
||||
import type { WidgetInstance } from "../types";
|
||||
|
||||
interface Props {
|
||||
widget: WidgetInstance;
|
||||
refreshIntervalMs: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function QbittorrentSpeedWidget({
|
||||
widget,
|
||||
refreshIntervalMs,
|
||||
description,
|
||||
}: Props) {
|
||||
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
|
||||
const series = data?.data?.series as ChartSeries[] | undefined;
|
||||
|
||||
return (
|
||||
<SectionCard title={widget.title} description={description}>
|
||||
{isLoading && !data ? (
|
||||
<Skeleton className="h-[220px] w-full" />
|
||||
) : data?.error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{data.error}</AlertDescription>
|
||||
</Alert>
|
||||
) : series && series.length > 0 ? (
|
||||
<LineSeriesChart series={series} height={220} />
|
||||
) : (
|
||||
<Alert>
|
||||
<AlertDescription>No speed data yet</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { SectionCard } from "../components/SectionCard";
|
||||
import { useWidgetData } from "../hooks/useWidgets";
|
||||
import type { WidgetInstance } from "../types";
|
||||
|
||||
interface Props {
|
||||
widget: WidgetInstance;
|
||||
refreshIntervalMs: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export function QbittorrentTotalsWidget({
|
||||
widget,
|
||||
refreshIntervalMs,
|
||||
description,
|
||||
}: Props) {
|
||||
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
|
||||
const payload = data?.data as
|
||||
| { total?: number; by_state?: Record<string, number> }
|
||||
| undefined;
|
||||
|
||||
return (
|
||||
<SectionCard title={widget.title} description={description}>
|
||||
{isLoading && !data ? (
|
||||
<Skeleton className="h-20 w-full" />
|
||||
) : data?.error ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{data.error}</AlertDescription>
|
||||
</Alert>
|
||||
) : payload ? (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="text-3xl font-semibold">{payload.total ?? 0}</div>
|
||||
<div className="text-xs text-muted-foreground">Total torrents</div>
|
||||
</div>
|
||||
{payload.by_state && Object.keys(payload.by_state).length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{Object.entries(payload.by_state).map(([state, count]) => (
|
||||
<Badge key={state} variant="secondary">
|
||||
{state}: {count}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { QbittorrentActiveTorrentsWidget } from "../QbittorrentActiveTorrentsWidget";
|
||||
import type { WidgetInstance } from "../../types";
|
||||
import * as useWidgets from "../../hooks/useWidgets";
|
||||
|
||||
vi.mock("../../hooks/useWidgets", () => ({
|
||||
useWidgetData: vi.fn(),
|
||||
}));
|
||||
|
||||
const widget: WidgetInstance = {
|
||||
id: "w1",
|
||||
service_id: "s1",
|
||||
widget_kind: "active",
|
||||
title: "Active Torrents",
|
||||
config: {},
|
||||
enabled: true,
|
||||
sort_order: 0,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
};
|
||||
|
||||
function mockData(data: unknown, error?: string) {
|
||||
vi.mocked(useWidgets.useWidgetData).mockReturnValue({
|
||||
data: error
|
||||
? { widget_id: "w1", error, fetched_at: 0 }
|
||||
: { widget_id: "w1", data, fetched_at: 0 },
|
||||
isLoading: false,
|
||||
} as unknown as ReturnType<typeof useWidgets.useWidgetData>);
|
||||
}
|
||||
|
||||
describe("QbittorrentActiveTorrentsWidget", () => {
|
||||
it("renders skeleton while loading", () => {
|
||||
vi.mocked(useWidgets.useWidgetData).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
} as unknown as ReturnType<typeof useWidgets.useWidgetData>);
|
||||
render(
|
||||
<QbittorrentActiveTorrentsWidget
|
||||
widget={widget}
|
||||
refreshIntervalMs={15000}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
document.querySelector('[data-slot="skeleton"]'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders active torrent rows", () => {
|
||||
mockData({
|
||||
torrents: [
|
||||
{
|
||||
name: "Movie.mkv",
|
||||
state: "downloading",
|
||||
size: 1000,
|
||||
progress: 0.5,
|
||||
dl_speed: 500000,
|
||||
up_speed: 1000,
|
||||
},
|
||||
{
|
||||
name: "Show.mkv",
|
||||
state: "uploading",
|
||||
size: 2000,
|
||||
progress: 1.0,
|
||||
dl_speed: 0,
|
||||
up_speed: 50000,
|
||||
},
|
||||
],
|
||||
});
|
||||
render(
|
||||
<QbittorrentActiveTorrentsWidget
|
||||
widget={widget}
|
||||
refreshIntervalMs={15000}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Movie.mkv")).toBeInTheDocument();
|
||||
expect(screen.getByText("Show.mkv")).toBeInTheDocument();
|
||||
expect(screen.getByText("downloading")).toBeInTheDocument();
|
||||
expect(screen.getByText("uploading")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows empty state when no active torrents", () => {
|
||||
mockData({ torrents: [] });
|
||||
render(
|
||||
<QbittorrentActiveTorrentsWidget
|
||||
widget={widget}
|
||||
refreshIntervalMs={15000}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/No active torrents/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error alert on error", () => {
|
||||
mockData(null, "qBittorrent fetch failed");
|
||||
render(
|
||||
<QbittorrentActiveTorrentsWidget
|
||||
widget={widget}
|
||||
refreshIntervalMs={15000}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/qBittorrent fetch failed/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { QbittorrentSpeedWidget } from "../QbittorrentSpeedWidget";
|
||||
import type { WidgetInstance } from "../../types";
|
||||
import * as useWidgets from "../../hooks/useWidgets";
|
||||
|
||||
vi.mock("../../hooks/useWidgets", () => ({
|
||||
useWidgetData: vi.fn(),
|
||||
}));
|
||||
|
||||
const widget: WidgetInstance = {
|
||||
id: "w1",
|
||||
service_id: "s1",
|
||||
widget_kind: "speed",
|
||||
title: "Speed Chart",
|
||||
config: {},
|
||||
enabled: true,
|
||||
sort_order: 0,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
};
|
||||
|
||||
function mockData(data: unknown, error?: string) {
|
||||
vi.mocked(useWidgets.useWidgetData).mockReturnValue({
|
||||
data: error
|
||||
? { widget_id: "w1", error, fetched_at: 0 }
|
||||
: { widget_id: "w1", data, fetched_at: 0 },
|
||||
isLoading: false,
|
||||
} as unknown as ReturnType<typeof useWidgets.useWidgetData>);
|
||||
}
|
||||
|
||||
describe("QbittorrentSpeedWidget", () => {
|
||||
it("renders skeleton while loading", () => {
|
||||
vi.mocked(useWidgets.useWidgetData).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
} as unknown as ReturnType<typeof useWidgets.useWidgetData>);
|
||||
render(<QbittorrentSpeedWidget widget={widget} refreshIntervalMs={5000} />);
|
||||
expect(
|
||||
document.querySelector('[data-slot="skeleton"]'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders chart with series data", () => {
|
||||
mockData({
|
||||
series: [
|
||||
{ label: "download", points: [{ t: 1000, v: 500000 }] },
|
||||
{ label: "upload", points: [{ t: 1000, v: 100000 }] },
|
||||
],
|
||||
});
|
||||
const { container } = render(
|
||||
<QbittorrentSpeedWidget widget={widget} refreshIntervalMs={5000} />,
|
||||
);
|
||||
expect(screen.getByText("Speed Chart")).toBeInTheDocument();
|
||||
expect(container.firstChild).not.toBeNull();
|
||||
});
|
||||
|
||||
it("shows empty state when no series", () => {
|
||||
mockData({ series: [] });
|
||||
render(<QbittorrentSpeedWidget widget={widget} refreshIntervalMs={5000} />);
|
||||
expect(screen.getByText(/No speed data yet/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error alert on error", () => {
|
||||
mockData(null, "qBittorrent fetch failed");
|
||||
render(<QbittorrentSpeedWidget widget={widget} refreshIntervalMs={5000} />);
|
||||
expect(screen.getByText(/qBittorrent fetch failed/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { QbittorrentTotalsWidget } from "../QbittorrentTotalsWidget";
|
||||
import type { WidgetInstance } from "../../types";
|
||||
import * as useWidgets from "../../hooks/useWidgets";
|
||||
|
||||
vi.mock("../../hooks/useWidgets", () => ({
|
||||
useWidgetData: vi.fn(),
|
||||
}));
|
||||
|
||||
const widget: WidgetInstance = {
|
||||
id: "w1",
|
||||
service_id: "s1",
|
||||
widget_kind: "totals",
|
||||
title: "Torrent Totals",
|
||||
config: {},
|
||||
enabled: true,
|
||||
sort_order: 0,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
};
|
||||
|
||||
function mockData(data: unknown, error?: string) {
|
||||
vi.mocked(useWidgets.useWidgetData).mockReturnValue({
|
||||
data: error
|
||||
? { widget_id: "w1", error, fetched_at: 0 }
|
||||
: { widget_id: "w1", data, fetched_at: 0 },
|
||||
isLoading: false,
|
||||
} as unknown as ReturnType<typeof useWidgets.useWidgetData>);
|
||||
}
|
||||
|
||||
describe("QbittorrentTotalsWidget", () => {
|
||||
it("renders skeleton while loading", () => {
|
||||
vi.mocked(useWidgets.useWidgetData).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
} as unknown as ReturnType<typeof useWidgets.useWidgetData>);
|
||||
render(
|
||||
<QbittorrentTotalsWidget widget={widget} refreshIntervalMs={30000} />,
|
||||
);
|
||||
expect(
|
||||
document.querySelector('[data-slot="skeleton"]'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders total count and state badges", () => {
|
||||
mockData({
|
||||
total: 4,
|
||||
by_state: { downloading: 1, uploading: 1, pausedDL: 2 },
|
||||
});
|
||||
render(
|
||||
<QbittorrentTotalsWidget widget={widget} refreshIntervalMs={30000} />,
|
||||
);
|
||||
expect(screen.getByText("4")).toBeInTheDocument();
|
||||
expect(screen.getByText("downloading: 1")).toBeInTheDocument();
|
||||
expect(screen.getByText("pausedDL: 2")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows error alert on error", () => {
|
||||
mockData(null, "qBittorrent fetch failed");
|
||||
render(
|
||||
<QbittorrentTotalsWidget widget={widget} refreshIntervalMs={30000} />,
|
||||
);
|
||||
expect(screen.getByText(/qBittorrent fetch failed/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,10 @@ export { PrometheusChartWidget } from "./PrometheusChartWidget";
|
||||
export { PrometheusGaugeWidget } from "./PrometheusGaugeWidget";
|
||||
export { PrometheusMeanWidget } from "./PrometheusMeanWidget";
|
||||
export { JellyfinWidget } from "./JellyfinWidget";
|
||||
export { JellyfinNowPlayingWidget } from "./JellyfinNowPlayingWidget";
|
||||
export { PrometheusMetricWidget } from "./PrometheusMetricWidget";
|
||||
export { QbittorrentActiveTorrentsWidget } from "./QbittorrentActiveTorrentsWidget";
|
||||
export { QbittorrentSpeedWidget } from "./QbittorrentSpeedWidget";
|
||||
export { QbittorrentTotalsWidget } from "./QbittorrentTotalsWidget";
|
||||
export { SshTaskWidget } from "./SshTaskWidget";
|
||||
export { StaticWidget } from "./StaticWidget";
|
||||
|
||||
Reference in New Issue
Block a user