From 7192dc27183255f9e5f48cc6cad9e643cd0ab5e4 Mon Sep 17 00:00:00 2001
From: shamoon <4887959+shamoon@users.noreply.github.com>
Date: Mon, 2 Feb 2026 22:16:44 -0800
Subject: [PATCH] Ok, make a reusable testing component
---
src/components/services/widget/block.test.jsx | 41 ++++++++
.../services/widget/container.test.jsx | 53 +++++++++++
src/test-utils/render-with-providers.jsx | 13 +++
src/widgets/omada/component.test.jsx | 31 +++----
src/widgets/pihole/component.test.jsx | 93 +++++++++++++++++++
vitest.config.mjs | 1 +
vitest.setup.js | 18 ++++
7 files changed, 230 insertions(+), 20 deletions(-)
create mode 100644 src/components/services/widget/block.test.jsx
create mode 100644 src/components/services/widget/container.test.jsx
create mode 100644 src/test-utils/render-with-providers.jsx
create mode 100644 src/widgets/pihole/component.test.jsx
diff --git a/src/components/services/widget/block.test.jsx b/src/components/services/widget/block.test.jsx
new file mode 100644
index 000000000..ece7518a7
--- /dev/null
+++ b/src/components/services/widget/block.test.jsx
@@ -0,0 +1,41 @@
+// @vitest-environment jsdom
+
+import { describe, expect, it } from "vitest";
+
+import Block from "./block";
+import { BlockHighlightContext } from "./highlight-context";
+
+import { renderWithProviders } from "test-utils/render-with-providers";
+
+describe("components/services/widget/block", () => {
+ it("renders a placeholder when value is undefined", () => {
+ const { container } = renderWithProviders(, { settings: {} });
+
+ // Value slot is rendered as "-" while loading.
+ expect(container.textContent).toContain("-");
+ expect(container.textContent).toContain("some.label");
+ });
+
+ it("sets highlight metadata when a rule matches", () => {
+ const highlightConfig = {
+ levels: { danger: "danger-class" },
+ fields: {
+ foo: {
+ numeric: { when: "gt", value: 10, level: "danger" },
+ },
+ },
+ };
+
+ const { container } = renderWithProviders(
+
+
+ ,
+ { settings: {} },
+ );
+
+ const el = container.querySelector(".service-block");
+ expect(el).not.toBeNull();
+ expect(el.getAttribute("data-highlight-level")).toBe("danger");
+ expect(el.className).toContain("danger-class");
+ });
+});
diff --git a/src/components/services/widget/container.test.jsx b/src/components/services/widget/container.test.jsx
new file mode 100644
index 000000000..a90984f5e
--- /dev/null
+++ b/src/components/services/widget/container.test.jsx
@@ -0,0 +1,53 @@
+// @vitest-environment jsdom
+
+import { screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+
+import { renderWithProviders } from "test-utils/render-with-providers";
+
+import Container from "./container";
+
+function Dummy({ label }) {
+ return
;
+}
+
+describe("components/services/widget/container", () => {
+ it("filters children based on widget.fields (auto-namespaced by widget type)", () => {
+ renderWithProviders(
+
+
+
+
+ ,
+ { settings: {} },
+ );
+
+ expect(screen.getByTestId("omada.connectedAp")).toBeInTheDocument();
+ expect(screen.getByTestId("omada.alerts")).toBeInTheDocument();
+ expect(screen.queryByTestId("omada.activeUser")).toBeNull();
+ });
+
+ it("accepts widget.fields as a JSON string", () => {
+ renderWithProviders(
+
+
+
+ ,
+ { settings: {} },
+ );
+
+ expect(screen.getByTestId("omada.alerts")).toBeInTheDocument();
+ expect(screen.queryByTestId("omada.connectedAp")).toBeNull();
+ });
+
+ it("supports aliased widget types when filtering (hoarder -> karakeep)", () => {
+ renderWithProviders(
+
+
+ ,
+ { settings: {} },
+ );
+
+ expect(screen.getByTestId("karakeep.count")).toBeInTheDocument();
+ });
+});
diff --git a/src/test-utils/render-with-providers.jsx b/src/test-utils/render-with-providers.jsx
new file mode 100644
index 000000000..7d6073fa9
--- /dev/null
+++ b/src/test-utils/render-with-providers.jsx
@@ -0,0 +1,13 @@
+import { render } from "@testing-library/react";
+
+import { SettingsContext } from "utils/contexts/settings";
+
+export function renderWithProviders(ui, { settings = {} } = {}) {
+ const value = {
+ settings,
+ // Most tests don't need to mutate settings; this keeps Container happy.
+ setSettings: () => {},
+ };
+
+ return render({ui});
+}
diff --git a/src/widgets/omada/component.test.jsx b/src/widgets/omada/component.test.jsx
index c7aa5447f..77f82abb9 100644
--- a/src/widgets/omada/component.test.jsx
+++ b/src/widgets/omada/component.test.jsx
@@ -1,40 +1,27 @@
// @vitest-environment jsdom
-import { render, screen } from "@testing-library/react";
+import { screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
-import { SettingsContext } from "utils/contexts/settings";
+import { renderWithProviders } from "test-utils/render-with-providers";
const { useWidgetAPI } = vi.hoisted(() => ({
useWidgetAPI: vi.fn(),
}));
-vi.mock("next-i18next", () => ({
- useTranslation: () => ({
- t: (key, opts) => {
- if (key === "common.number") return String(opts?.value ?? "");
- return key;
- },
- }),
-}));
-
vi.mock("../../utils/proxy/use-widget-api", () => ({
default: useWidgetAPI,
}));
import Component from "./component";
-function renderWithSettings(ui) {
- return render(
- {} }}>{ui},
- );
-}
-
describe("widgets/omada/component", () => {
it("renders error UI when widget API errors", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: { message: "nope" } });
- renderWithSettings();
+ renderWithProviders(, {
+ settings: { hideErrors: false },
+ });
expect(screen.getAllByText(/widget\.api_error/i).length).toBeGreaterThan(0);
});
@@ -42,7 +29,9 @@ describe("widgets/omada/component", () => {
it("renders placeholders while loading and defaults fields to 4 visible blocks", () => {
useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
- const { container } = renderWithSettings();
+ const { container } = renderWithProviders(, {
+ settings: { hideErrors: false },
+ });
// Default fields do not include connectedSwitches, so Container filters it out.
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
@@ -68,7 +57,9 @@ describe("widgets/omada/component", () => {
error: undefined,
});
- const { container } = renderWithSettings();
+ const { container } = renderWithProviders(, {
+ settings: { hideErrors: false },
+ });
expect(container.querySelectorAll(".service-block")).toHaveLength(4);
expect(screen.getByText("1")).toBeInTheDocument();
diff --git a/src/widgets/pihole/component.test.jsx b/src/widgets/pihole/component.test.jsx
new file mode 100644
index 000000000..2a5975938
--- /dev/null
+++ b/src/widgets/pihole/component.test.jsx
@@ -0,0 +1,93 @@
+// @vitest-environment jsdom
+
+import { screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { renderWithProviders } from "test-utils/render-with-providers";
+
+const { useWidgetAPI } = vi.hoisted(() => ({
+ useWidgetAPI: vi.fn(),
+}));
+
+vi.mock("utils/proxy/use-widget-api", () => ({
+ default: useWidgetAPI,
+}));
+
+import Component from "./component";
+
+describe("widgets/pihole/component", () => {
+ it("renders error UI when widget API errors", () => {
+ useWidgetAPI.mockReturnValue({ data: undefined, error: { message: "nope" } });
+
+ renderWithProviders(, {
+ settings: { hideErrors: false },
+ });
+
+ expect(screen.getAllByText(/widget\.api_error/i).length).toBeGreaterThan(0);
+ });
+
+ it("renders placeholders while loading and defaults fields (3 visible blocks)", () => {
+ useWidgetAPI.mockReturnValue({ data: undefined, error: undefined });
+
+ const { container } = renderWithProviders(, {
+ settings: { hideErrors: false },
+ });
+
+ // Default fields are queries/blocked/gravity; blocked_percent is present in JSX but filtered out by Container.
+ expect(container.querySelectorAll(".service-block")).toHaveLength(3);
+ expect(screen.getByText("pihole.queries")).toBeInTheDocument();
+ expect(screen.getByText("pihole.blocked")).toBeInTheDocument();
+ expect(screen.getByText("pihole.gravity")).toBeInTheDocument();
+ expect(screen.queryByText("pihole.blocked_percent")).toBeNull();
+
+ expect(screen.getAllByText("-")).toHaveLength(3);
+ });
+
+ it("renders values and appends percent to blocked when blocked_percent is not a field", () => {
+ useWidgetAPI.mockReturnValue({
+ data: {
+ ads_blocked_today: "5",
+ ads_percentage_today: "12.345",
+ dns_queries_today: "99",
+ domains_being_blocked: "123",
+ },
+ error: undefined,
+ });
+
+ const { container } = renderWithProviders(, {
+ settings: { hideErrors: false },
+ });
+
+ expect(container.querySelectorAll(".service-block")).toHaveLength(3);
+
+ // common.number/common.percent are formatted by the test i18n stub in vitest.setup.js
+ expect(screen.getByText("99")).toBeInTheDocument();
+ expect(screen.getByText("123")).toBeInTheDocument();
+ expect(screen.getByText("5 (12.3)")).toBeInTheDocument();
+ });
+
+ it("renders blocked_percent as its own block when configured", () => {
+ useWidgetAPI.mockReturnValue({
+ data: {
+ ads_blocked_today: "5",
+ ads_percentage_today: "12.345",
+ dns_queries_today: "99",
+ domains_being_blocked: "123",
+ },
+ error: undefined,
+ });
+
+ const { container } = renderWithProviders(
+ ,
+ { settings: { hideErrors: false } },
+ );
+
+ expect(container.querySelectorAll(".service-block")).toHaveLength(4);
+ expect(screen.getByText("5")).toBeInTheDocument(); // blocked (no percent appended)
+ expect(screen.getByText("12.3")).toBeInTheDocument(); // blocked_percent
+ });
+});
diff --git a/vitest.config.mjs b/vitest.config.mjs
index 1568484e6..06bb1cbac 100644
--- a/vitest.config.mjs
+++ b/vitest.config.mjs
@@ -10,6 +10,7 @@ export default defineConfig({
resolve: {
alias: {
components: fileURLToPath(new URL("./src/components", import.meta.url)),
+ "test-utils": fileURLToPath(new URL("./src/test-utils", import.meta.url)),
utils: fileURLToPath(new URL("./src/utils", import.meta.url)),
widgets: fileURLToPath(new URL("./src/widgets", import.meta.url)),
},
diff --git a/vitest.setup.js b/vitest.setup.js
index f149f27ae..946eff05e 100644
--- a/vitest.setup.js
+++ b/vitest.setup.js
@@ -1 +1,19 @@
import "@testing-library/jest-dom/vitest";
+
+import { cleanup } from "@testing-library/react";
+import { afterEach, vi } from "vitest";
+
+afterEach(() => {
+ cleanup();
+});
+
+// implement a couple of common formatters mocked in next-i18next
+vi.mock("next-i18next", () => ({
+ useTranslation: () => ({
+ t: (key, opts) => {
+ if (key === "common.number") return String(opts?.value ?? "");
+ if (key === "common.percent") return String(opts?.value ?? "");
+ return key;
+ },
+ }),
+}));