From 4c3d39709b5a13749deef114f5ecfe0067aaa9e2 Mon Sep 17 00:00:00 2001
From: shamoon <4887959+shamoon@users.noreply.github.com>
Date: Wed, 4 Feb 2026 09:33:08 -0800
Subject: [PATCH] test: finish utils coverage (contexts, kubernetes helpers,
weather maps)
---
src/utils/contexts/color.jsx | 9 +-
src/utils/contexts/color.test.jsx | 39 ++++++
src/utils/contexts/settings.jsx | 10 +-
src/utils/contexts/settings.test.jsx | 33 +++++
src/utils/contexts/tab.jsx | 10 +-
src/utils/contexts/tab.test.jsx | 33 +++++
src/utils/contexts/theme.jsx | 9 +-
src/utils/contexts/theme.test.jsx | 33 +++++
src/utils/kubernetes/export.test.js | 28 ++++
src/utils/kubernetes/httproute-list.test.js | 77 ++++++++++
src/utils/kubernetes/ingress-list.js | 2 +-
src/utils/kubernetes/ingress-list.test.js | 79 +++++++++++
src/utils/kubernetes/resource-helpers.test.js | 131 ++++++++++++++++++
src/utils/kubernetes/traefik-list.test.js | 78 +++++++++++
src/utils/kubernetes/utils.test.js | 22 +++
src/utils/styles/themes.test.js | 17 +++
src/utils/weather/condition-map.test.js | 15 ++
.../weather/openmeteo-condition-map.test.js | 15 ++
src/utils/weather/owm-condition-map.test.js | 15 ++
19 files changed, 636 insertions(+), 19 deletions(-)
create mode 100644 src/utils/contexts/color.test.jsx
create mode 100644 src/utils/contexts/settings.test.jsx
create mode 100644 src/utils/contexts/tab.test.jsx
create mode 100644 src/utils/contexts/theme.test.jsx
create mode 100644 src/utils/kubernetes/export.test.js
create mode 100644 src/utils/kubernetes/httproute-list.test.js
create mode 100644 src/utils/kubernetes/ingress-list.test.js
create mode 100644 src/utils/kubernetes/resource-helpers.test.js
create mode 100644 src/utils/kubernetes/traefik-list.test.js
create mode 100644 src/utils/kubernetes/utils.test.js
create mode 100644 src/utils/styles/themes.test.js
create mode 100644 src/utils/weather/condition-map.test.js
create mode 100644 src/utils/weather/openmeteo-condition-map.test.js
create mode 100644 src/utils/weather/owm-condition-map.test.js
diff --git a/src/utils/contexts/color.jsx b/src/utils/contexts/color.jsx
index 623d31f84..909b8e986 100644
--- a/src/utils/contexts/color.jsx
+++ b/src/utils/contexts/color.jsx
@@ -17,7 +17,7 @@ const getInitialColor = () => {
export const ColorContext = createContext();
export function ColorProvider({ initialTheme, children }) {
- const [color, setColor] = useState(getInitialColor);
+ const [color, setColor] = useState(() => initialTheme ?? getInitialColor());
const rawSetColor = (rawColor) => {
const root = window.document.documentElement;
@@ -30,9 +30,10 @@ export function ColorProvider({ initialTheme, children }) {
lastColor = rawColor;
};
- if (initialTheme) {
- rawSetColor(initialTheme);
- }
+ useEffect(() => {
+ if (initialTheme !== undefined) setColor(initialTheme ?? getInitialColor());
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [initialTheme]);
useEffect(() => {
rawSetColor(color);
diff --git a/src/utils/contexts/color.test.jsx b/src/utils/contexts/color.test.jsx
new file mode 100644
index 000000000..738cd61c8
--- /dev/null
+++ b/src/utils/contexts/color.test.jsx
@@ -0,0 +1,39 @@
+// @vitest-environment jsdom
+
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { useContext } from "react";
+import { describe, expect, it } from "vitest";
+
+import { ColorContext, ColorProvider } from "./color";
+
+function Reader() {
+ const { color, setColor } = useContext(ColorContext);
+ return (
+
+
{color}
+
+
+ );
+}
+
+describe("utils/contexts/color", () => {
+ it("initializes from localStorage and writes theme class + storage on updates", async () => {
+ localStorage.setItem("theme-color", "blue");
+ document.documentElement.className = "";
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("value")).toHaveTextContent("blue");
+ await waitFor(() => expect(document.documentElement.classList.contains("theme-blue")).toBe(true));
+
+ fireEvent.click(screen.getByRole("button", { name: "red" }));
+ await waitFor(() => expect(document.documentElement.classList.contains("theme-red")).toBe(true));
+ expect(localStorage.getItem("theme-color")).toBe("red");
+ });
+});
diff --git a/src/utils/contexts/settings.jsx b/src/utils/contexts/settings.jsx
index 764519530..00699b41a 100644
--- a/src/utils/contexts/settings.jsx
+++ b/src/utils/contexts/settings.jsx
@@ -1,13 +1,13 @@
-import { createContext, useMemo, useState } from "react";
+import { createContext, useEffect, useMemo, useState } from "react";
export const SettingsContext = createContext();
export function SettingsProvider({ initialSettings, children }) {
- const [settings, setSettings] = useState({});
+ const [settings, setSettings] = useState(() => initialSettings ?? {});
- if (initialSettings) {
- setSettings(initialSettings);
- }
+ useEffect(() => {
+ if (initialSettings !== undefined) setSettings(initialSettings ?? {});
+ }, [initialSettings]);
const value = useMemo(() => ({ settings, setSettings }), [settings]);
diff --git a/src/utils/contexts/settings.test.jsx b/src/utils/contexts/settings.test.jsx
new file mode 100644
index 000000000..2a6abd5bb
--- /dev/null
+++ b/src/utils/contexts/settings.test.jsx
@@ -0,0 +1,33 @@
+// @vitest-environment jsdom
+
+import { fireEvent, render, screen } from "@testing-library/react";
+import { useContext } from "react";
+import { describe, expect, it } from "vitest";
+
+import { SettingsContext, SettingsProvider } from "./settings";
+
+function Reader() {
+ const { settings, setSettings } = useContext(SettingsContext);
+ return (
+
+
{JSON.stringify(settings)}
+
+
+ );
+}
+
+describe("utils/contexts/settings", () => {
+ it("provides initial settings and allows updates", () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("value")).toHaveTextContent('{"a":1}');
+ fireEvent.click(screen.getByRole("button", { name: "update" }));
+ expect(screen.getByTestId("value")).toHaveTextContent('{"updated":true}');
+ });
+});
diff --git a/src/utils/contexts/tab.jsx b/src/utils/contexts/tab.jsx
index 2a3d34578..c18fc2c94 100644
--- a/src/utils/contexts/tab.jsx
+++ b/src/utils/contexts/tab.jsx
@@ -1,13 +1,13 @@
-import { createContext, useMemo, useState } from "react";
+import { createContext, useEffect, useMemo, useState } from "react";
export const TabContext = createContext();
export function TabProvider({ initialTab, children }) {
- const [activeTab, setActiveTab] = useState(false);
+ const [activeTab, setActiveTab] = useState(() => initialTab ?? false);
- if (initialTab) {
- setActiveTab(initialTab);
- }
+ useEffect(() => {
+ if (initialTab !== undefined) setActiveTab(initialTab ?? false);
+ }, [initialTab]);
const value = useMemo(() => ({ activeTab, setActiveTab }), [activeTab]);
diff --git a/src/utils/contexts/tab.test.jsx b/src/utils/contexts/tab.test.jsx
new file mode 100644
index 000000000..5b051edfa
--- /dev/null
+++ b/src/utils/contexts/tab.test.jsx
@@ -0,0 +1,33 @@
+// @vitest-environment jsdom
+
+import { fireEvent, render, screen } from "@testing-library/react";
+import { useContext } from "react";
+import { describe, expect, it } from "vitest";
+
+import { TabContext, TabProvider } from "./tab";
+
+function Reader() {
+ const { activeTab, setActiveTab } = useContext(TabContext);
+ return (
+
+
{String(activeTab)}
+
+
+ );
+}
+
+describe("utils/contexts/tab", () => {
+ it("provides initial tab and allows updates", () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("value")).toHaveTextContent("first");
+ fireEvent.click(screen.getByRole("button", { name: "next" }));
+ expect(screen.getByTestId("value")).toHaveTextContent("next");
+ });
+});
diff --git a/src/utils/contexts/theme.jsx b/src/utils/contexts/theme.jsx
index 2c48e8347..76dd37800 100644
--- a/src/utils/contexts/theme.jsx
+++ b/src/utils/contexts/theme.jsx
@@ -19,7 +19,7 @@ const getInitialTheme = () => {
export const ThemeContext = createContext();
export function ThemeProvider({ initialTheme, children }) {
- const [theme, setTheme] = useState(getInitialTheme);
+ const [theme, setTheme] = useState(() => initialTheme ?? getInitialTheme());
const rawSetTheme = (rawTheme) => {
const root = window.document.documentElement;
@@ -31,9 +31,10 @@ export function ThemeProvider({ initialTheme, children }) {
localStorage.setItem("theme-mode", rawTheme);
};
- if (initialTheme) {
- rawSetTheme(initialTheme);
- }
+ useEffect(() => {
+ if (initialTheme !== undefined) setTheme(initialTheme ?? getInitialTheme());
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [initialTheme]);
useEffect(() => {
rawSetTheme(theme);
diff --git a/src/utils/contexts/theme.test.jsx b/src/utils/contexts/theme.test.jsx
new file mode 100644
index 000000000..e4ede3def
--- /dev/null
+++ b/src/utils/contexts/theme.test.jsx
@@ -0,0 +1,33 @@
+// @vitest-environment jsdom
+
+import { render, screen, waitFor } from "@testing-library/react";
+import { useContext } from "react";
+import { describe, expect, it, vi } from "vitest";
+
+import { ThemeContext, ThemeProvider } from "./theme";
+
+function Reader() {
+ const { theme } = useContext(ThemeContext);
+ return {theme}
;
+}
+
+describe("utils/contexts/theme", () => {
+ it("initializes from localStorage and writes html classes", async () => {
+ // jsdom doesn't implement matchMedia by default; ensure it exists for getInitialTheme.
+ window.matchMedia =
+ window.matchMedia || vi.fn(() => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() }));
+
+ localStorage.setItem("theme-mode", "light");
+ document.documentElement.className = "";
+
+ render(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId("value")).toHaveTextContent("light");
+ await waitFor(() => expect(document.documentElement.classList.contains("light")).toBe(true));
+ expect(localStorage.getItem("theme-mode")).toBe("light");
+ });
+});
diff --git a/src/utils/kubernetes/export.test.js b/src/utils/kubernetes/export.test.js
new file mode 100644
index 000000000..142d28357
--- /dev/null
+++ b/src/utils/kubernetes/export.test.js
@@ -0,0 +1,28 @@
+import { describe, expect, it, vi } from "vitest";
+
+const { listIngress, listTraefikIngress, listHttpRoute, isDiscoverable, constructedServiceFromResource } = vi.hoisted(
+ () => ({
+ listIngress: vi.fn(),
+ listTraefikIngress: vi.fn(),
+ listHttpRoute: vi.fn(),
+ isDiscoverable: vi.fn(),
+ constructedServiceFromResource: vi.fn(),
+ }),
+);
+
+vi.mock("utils/kubernetes/ingress-list", () => ({ default: listIngress }));
+vi.mock("utils/kubernetes/traefik-list", () => ({ default: listTraefikIngress }));
+vi.mock("utils/kubernetes/httproute-list", () => ({ default: listHttpRoute }));
+vi.mock("utils/kubernetes/resource-helpers", () => ({ isDiscoverable, constructedServiceFromResource }));
+
+import kubernetes from "./export";
+
+describe("utils/kubernetes/export", () => {
+ it("re-exports kubernetes helper functions", () => {
+ expect(kubernetes.listIngress).toBe(listIngress);
+ expect(kubernetes.listTraefikIngress).toBe(listTraefikIngress);
+ expect(kubernetes.listHttpRoute).toBe(listHttpRoute);
+ expect(kubernetes.isDiscoverable).toBe(isDiscoverable);
+ expect(kubernetes.constructedServiceFromResource).toBe(constructedServiceFromResource);
+ });
+});
diff --git a/src/utils/kubernetes/httproute-list.test.js b/src/utils/kubernetes/httproute-list.test.js
new file mode 100644
index 000000000..f8e9eda24
--- /dev/null
+++ b/src/utils/kubernetes/httproute-list.test.js
@@ -0,0 +1,77 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const { state, getKubernetes, getKubeConfig, logger } = vi.hoisted(() => {
+ const state = {
+ enabled: true,
+ namespaces: ["a", "b"],
+ routesByNs: {
+ a: [{ metadata: { name: "r1" } }],
+ b: [{ metadata: { name: "r2" } }],
+ },
+ crd: {
+ listNamespacedCustomObject: vi.fn(async ({ namespace }) => ({ items: state.routesByNs[namespace] ?? [] })),
+ },
+ core: {
+ listNamespace: vi.fn(async () => ({ items: state.namespaces.map((n) => ({ metadata: { name: n } })) })),
+ },
+ kc: {
+ makeApiClient: vi.fn((Api) => (Api.name === "CoreV1Api" ? state.core : state.crd)),
+ },
+ };
+
+ return {
+ state,
+ getKubernetes: vi.fn(() => ({ gateway: state.enabled })),
+ getKubeConfig: vi.fn(() => state.kc),
+ logger: { error: vi.fn(), debug: vi.fn() },
+ };
+});
+
+vi.mock("@kubernetes/client-node", () => ({
+ CoreV1Api: class CoreV1Api {},
+ CustomObjectsApi: class CustomObjectsApi {},
+}));
+
+vi.mock("utils/config/kubernetes", () => ({
+ getKubeConfig,
+ getKubernetes,
+ HTTPROUTE_API_GROUP: "gateway.networking.k8s.io",
+ HTTPROUTE_API_VERSION: "v1",
+}));
+
+vi.mock("utils/logger", () => ({
+ default: () => logger,
+}));
+
+describe("utils/kubernetes/httproute-list", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ state.enabled = true;
+ state.namespaces = ["a", "b"];
+ state.routesByNs = {
+ a: [{ metadata: { name: "r1" } }],
+ b: [{ metadata: { name: "r2" } }],
+ };
+ });
+
+ it("returns an empty list when gateway discovery is disabled", async () => {
+ state.enabled = false;
+ vi.resetModules();
+ const listHttpRoute = (await import("./httproute-list")).default;
+
+ const result = await listHttpRoute();
+
+ expect(result).toEqual([]);
+ });
+
+ it("lists namespaces and aggregates httproutes", async () => {
+ vi.resetModules();
+ const listHttpRoute = (await import("./httproute-list")).default;
+
+ const result = await listHttpRoute();
+
+ expect(result.map((r) => r.metadata.name)).toEqual(["r1", "r2"]);
+ expect(state.core.listNamespace).toHaveBeenCalled();
+ expect(state.crd.listNamespacedCustomObject).toHaveBeenCalledTimes(2);
+ });
+});
diff --git a/src/utils/kubernetes/ingress-list.js b/src/utils/kubernetes/ingress-list.js
index 1cd9ca952..0523fefe1 100644
--- a/src/utils/kubernetes/ingress-list.js
+++ b/src/utils/kubernetes/ingress-list.js
@@ -20,7 +20,7 @@ export default async function listIngress() {
logger.debug(error);
return null;
});
- ingressList = ingressData.items;
+ ingressList = ingressData?.items ?? [];
}
return ingressList;
}
diff --git a/src/utils/kubernetes/ingress-list.test.js b/src/utils/kubernetes/ingress-list.test.js
new file mode 100644
index 000000000..4e1cdc76f
--- /dev/null
+++ b/src/utils/kubernetes/ingress-list.test.js
@@ -0,0 +1,79 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const { state, getKubernetes, getKubeConfig, logger } = vi.hoisted(() => {
+ const state = {
+ ingressEnabled: true,
+ items: [],
+ throw: null,
+ networking: {
+ listIngressForAllNamespaces: vi.fn(async () => {
+ if (state.throw) throw state.throw;
+ return { items: state.items };
+ }),
+ },
+ kc: {
+ makeApiClient: vi.fn(() => state.networking),
+ },
+ };
+
+ return {
+ state,
+ getKubernetes: vi.fn(() => ({ ingress: state.ingressEnabled })),
+ getKubeConfig: vi.fn(() => state.kc),
+ logger: { error: vi.fn(), debug: vi.fn() },
+ };
+});
+
+vi.mock("@kubernetes/client-node", () => ({
+ NetworkingV1Api: class NetworkingV1Api {},
+}));
+
+vi.mock("utils/config/kubernetes", () => ({
+ getKubernetes,
+ getKubeConfig,
+}));
+
+vi.mock("utils/logger", () => ({
+ default: () => logger,
+}));
+
+describe("utils/kubernetes/ingress-list", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ state.ingressEnabled = true;
+ state.items = [];
+ state.throw = null;
+ });
+
+ it("returns an empty list when ingress discovery is disabled", async () => {
+ state.ingressEnabled = false;
+ vi.resetModules();
+ const listIngress = (await import("./ingress-list")).default;
+
+ const result = await listIngress();
+
+ expect(result).toEqual([]);
+ expect(state.networking.listIngressForAllNamespaces).not.toHaveBeenCalled();
+ });
+
+ it("returns items from listIngressForAllNamespaces", async () => {
+ state.items = [{ metadata: { name: "i1" } }];
+ vi.resetModules();
+ const listIngress = (await import("./ingress-list")).default;
+
+ const result = await listIngress();
+
+ expect(result).toEqual([{ metadata: { name: "i1" } }]);
+ });
+
+ it("returns an empty list on errors", async () => {
+ state.throw = { statusCode: 500, body: "nope", response: "x" };
+ vi.resetModules();
+ const listIngress = (await import("./ingress-list")).default;
+
+ const result = await listIngress();
+
+ expect(result).toEqual([]);
+ expect(logger.error).toHaveBeenCalled();
+ });
+});
diff --git a/src/utils/kubernetes/resource-helpers.test.js b/src/utils/kubernetes/resource-helpers.test.js
new file mode 100644
index 000000000..0ea12cb33
--- /dev/null
+++ b/src/utils/kubernetes/resource-helpers.test.js
@@ -0,0 +1,131 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const { state, substituteEnvironmentVars, getKubeConfig, logger } = vi.hoisted(() => {
+ const state = {
+ gatewayProtocol: "https",
+ };
+
+ const substituteEnvironmentVars = vi.fn((raw) =>
+ raw.replaceAll("${DESC}", process.env.DESC ?? "").replaceAll("${ICON}", process.env.ICON ?? ""),
+ );
+
+ const crd = {
+ getNamespacedCustomObject: vi.fn(async () => ({
+ spec: { listeners: [{ name: "web", protocol: state.gatewayProtocol.toUpperCase() }] },
+ })),
+ };
+
+ const kc = {
+ makeApiClient: vi.fn(() => crd),
+ };
+
+ return {
+ state,
+ substituteEnvironmentVars,
+ getKubeConfig: vi.fn(() => kc),
+ logger: { error: vi.fn(), debug: vi.fn() },
+ };
+});
+
+vi.mock("@kubernetes/client-node", () => ({
+ CustomObjectsApi: class CustomObjectsApi {},
+}));
+
+vi.mock("utils/config/config", () => ({
+ substituteEnvironmentVars,
+}));
+
+vi.mock("utils/config/kubernetes", () => ({
+ ANNOTATION_BASE: "gethomepage.dev",
+ ANNOTATION_WIDGET_BASE: "gethomepage.dev/widget.",
+ HTTPROUTE_API_GROUP: "gateway.networking.k8s.io",
+ HTTPROUTE_API_VERSION: "v1",
+ getKubeConfig,
+}));
+
+vi.mock("utils/logger", () => ({
+ default: () => logger,
+}));
+
+import { constructedServiceFromResource, isDiscoverable } from "./resource-helpers";
+
+describe("utils/kubernetes/resource-helpers", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ process.env.DESC = "desc";
+ process.env.ICON = "mdi:test";
+ state.gatewayProtocol = "https";
+ });
+
+ it("checks discoverability by annotations and instance", () => {
+ const base = "gethomepage.dev";
+ const resource = { metadata: { annotations: { [`${base}/enabled`]: "true" } } };
+
+ expect(isDiscoverable(resource, "x")).toBe(true);
+ expect(isDiscoverable({ metadata: { annotations: { [`${base}/enabled`]: "false" } } }, "x")).toBe(false);
+ expect(
+ isDiscoverable({ metadata: { annotations: { [`${base}/enabled`]: "true", [`${base}/instance`]: "x" } } }, "x"),
+ ).toBe(true);
+ expect(
+ isDiscoverable({ metadata: { annotations: { [`${base}/enabled`]: "true", [`${base}/instance.y`]: "1" } } }, "y"),
+ ).toBe(true);
+ });
+
+ it("constructs a service from an ingress and applies widget annotations + env substitution", async () => {
+ const base = "gethomepage.dev";
+ const resource = {
+ kind: "Ingress",
+ metadata: {
+ name: "app",
+ namespace: "ns",
+ annotations: {
+ [`${base}/external`]: "TRUE",
+ [`${base}/description`]: "${DESC}",
+ [`${base}/icon`]: "${ICON}",
+ [`${base}/widget.type`]: "kubernetes",
+ [`${base}/widget.url`]: "http://x",
+ },
+ },
+ spec: {
+ tls: [{}],
+ rules: [{ host: "example.com", http: { paths: [{ path: "/app" }] } }],
+ },
+ };
+
+ const service = await constructedServiceFromResource(resource);
+
+ expect(service.href).toBe("https://example.com/app");
+ expect(service.external).toBe(true);
+ expect(service.description).toBe("desc");
+ expect(service.icon).toBe("mdi:test");
+ expect(service.widget.type).toBe("kubernetes");
+ expect(service.widget.url).toBe("http://x");
+ expect(substituteEnvironmentVars).toHaveBeenCalled();
+ });
+
+ it("constructs a href from an HTTPRoute using the gateway listener protocol", async () => {
+ const base = "gethomepage.dev";
+ const resource = {
+ kind: "HTTPRoute",
+ metadata: {
+ name: "route",
+ namespace: "ns",
+ annotations: {
+ [`${base}/enabled`]: "true",
+ },
+ },
+ spec: {
+ hostnames: ["example.com"],
+ parentRefs: [{ namespace: "ns", name: "gw", sectionName: "web" }],
+ rules: [
+ {
+ matches: [{ path: { type: "PathPrefix", value: "/r" } }],
+ },
+ ],
+ },
+ };
+
+ const service = await constructedServiceFromResource(resource);
+ expect(service.href).toBe("https://example.com/r");
+ });
+});
diff --git a/src/utils/kubernetes/traefik-list.test.js b/src/utils/kubernetes/traefik-list.test.js
new file mode 100644
index 000000000..a7aa04aa8
--- /dev/null
+++ b/src/utils/kubernetes/traefik-list.test.js
@@ -0,0 +1,78 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const { state, getKubernetes, getKubeConfig, checkCRD, logger } = vi.hoisted(() => {
+ const state = {
+ enabled: true,
+ containoItems: [],
+ ioItems: [],
+ crd: {
+ listClusterCustomObject: vi.fn(async ({ group }) => {
+ if (group === "traefik.containo.us") return { items: state.containoItems };
+ if (group === "traefik.io") return { items: state.ioItems };
+ return { items: [] };
+ }),
+ },
+ kc: {
+ makeApiClient: vi.fn(() => state.crd),
+ },
+ };
+
+ return {
+ state,
+ getKubernetes: vi.fn(() => ({ traefik: state.enabled })),
+ getKubeConfig: vi.fn(() => state.kc),
+ checkCRD: vi.fn(async () => true),
+ logger: { error: vi.fn(), debug: vi.fn() },
+ };
+});
+
+vi.mock("@kubernetes/client-node", () => ({
+ CustomObjectsApi: class CustomObjectsApi {},
+}));
+
+vi.mock("utils/config/kubernetes", () => ({
+ ANNOTATION_BASE: "gethomepage.dev",
+ checkCRD,
+ getKubeConfig,
+ getKubernetes,
+}));
+
+vi.mock("utils/logger", () => ({
+ default: () => logger,
+}));
+
+describe("utils/kubernetes/traefik-list", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ state.enabled = true;
+ state.containoItems = [];
+ state.ioItems = [];
+ });
+
+ it("returns an empty list when traefik discovery is disabled", async () => {
+ state.enabled = false;
+ vi.resetModules();
+ const listTraefikIngress = (await import("./traefik-list")).default;
+
+ const result = await listTraefikIngress();
+
+ expect(result).toEqual([]);
+ });
+
+ it("filters and merges ingressroutes with homepage href annotations", async () => {
+ state.containoItems = [
+ { metadata: { annotations: { "gethomepage.dev/href": "http://a" } } },
+ { metadata: { annotations: {} } },
+ ];
+ state.ioItems = [{ metadata: { annotations: { "gethomepage.dev/href": "http://b" } } }];
+ vi.resetModules();
+ const listTraefikIngress = (await import("./traefik-list")).default;
+
+ const result = await listTraefikIngress();
+
+ expect(result).toHaveLength(2);
+ expect(result[0].metadata.annotations["gethomepage.dev/href"]).toBe("http://a");
+ expect(result[1].metadata.annotations["gethomepage.dev/href"]).toBe("http://b");
+ expect(checkCRD).toHaveBeenCalled();
+ });
+});
diff --git a/src/utils/kubernetes/utils.test.js b/src/utils/kubernetes/utils.test.js
new file mode 100644
index 000000000..bea9506f5
--- /dev/null
+++ b/src/utils/kubernetes/utils.test.js
@@ -0,0 +1,22 @@
+import { describe, expect, it } from "vitest";
+
+import { parseCpu, parseMemory } from "./utils";
+
+describe("utils/kubernetes/utils", () => {
+ it("parses cpu units into core values", () => {
+ expect(parseCpu("500m")).toBeCloseTo(0.5);
+ expect(parseCpu("250u")).toBeCloseTo(0.00025);
+ expect(parseCpu("1000n")).toBeCloseTo(0.000001);
+ expect(parseCpu("2")).toBe(2);
+ });
+
+ it("parses memory units into numeric values", () => {
+ expect(parseMemory("1Gi")).toBe(1000000000);
+ expect(parseMemory("1G")).toBe(1024 * 1024 * 1024);
+ expect(parseMemory("1Mi")).toBe(1000000);
+ expect(parseMemory("1M")).toBe(1024 * 1024);
+ expect(parseMemory("1Ki")).toBe(1000);
+ expect(parseMemory("1K")).toBe(1024);
+ expect(parseMemory("256")).toBe(256);
+ });
+});
diff --git a/src/utils/styles/themes.test.js b/src/utils/styles/themes.test.js
new file mode 100644
index 000000000..7daacee24
--- /dev/null
+++ b/src/utils/styles/themes.test.js
@@ -0,0 +1,17 @@
+import { describe, expect, it } from "vitest";
+
+import themes from "./themes";
+
+describe("utils/styles/themes", () => {
+ it("contains expected theme palettes", () => {
+ expect(themes).toHaveProperty("slate");
+ expect(themes.slate).toEqual(
+ expect.objectContaining({
+ light: expect.stringMatching(/^#[0-9a-f]{6}$/i),
+ dark: expect.stringMatching(/^#[0-9a-f]{6}$/i),
+ iconStart: expect.stringMatching(/^#[0-9a-f]{6}$/i),
+ iconEnd: expect.stringMatching(/^#[0-9a-f]{6}$/i),
+ }),
+ );
+ });
+});
diff --git a/src/utils/weather/condition-map.test.js b/src/utils/weather/condition-map.test.js
new file mode 100644
index 000000000..c74635b9c
--- /dev/null
+++ b/src/utils/weather/condition-map.test.js
@@ -0,0 +1,15 @@
+import * as Icons from "react-icons/wi";
+import { describe, expect, it } from "vitest";
+
+import mapIcon from "./condition-map";
+
+describe("utils/weather/condition-map", () => {
+ it("maps known condition codes to day/night icons", () => {
+ expect(mapIcon(1000, "day")).toBe(Icons.WiDaySunny);
+ expect(mapIcon(1000, "night")).toBe(Icons.WiNightClear);
+ });
+
+ it("falls back to a default icon for unknown codes", () => {
+ expect(mapIcon(999999, "day")).toBe(Icons.WiDaySunny);
+ });
+});
diff --git a/src/utils/weather/openmeteo-condition-map.test.js b/src/utils/weather/openmeteo-condition-map.test.js
new file mode 100644
index 000000000..40e301e67
--- /dev/null
+++ b/src/utils/weather/openmeteo-condition-map.test.js
@@ -0,0 +1,15 @@
+import * as Icons from "react-icons/wi";
+import { describe, expect, it } from "vitest";
+
+import mapIcon from "./openmeteo-condition-map";
+
+describe("utils/weather/openmeteo-condition-map", () => {
+ it("maps known condition codes to day/night icons", () => {
+ expect(mapIcon(95, "day")).toBe(Icons.WiDayThunderstorm);
+ expect(mapIcon(95, "night")).toBe(Icons.WiNightAltThunderstorm);
+ });
+
+ it("falls back to a default icon for unknown codes", () => {
+ expect(mapIcon(999999, "day")).toBe(Icons.WiDaySunny);
+ });
+});
diff --git a/src/utils/weather/owm-condition-map.test.js b/src/utils/weather/owm-condition-map.test.js
new file mode 100644
index 000000000..2094eac0b
--- /dev/null
+++ b/src/utils/weather/owm-condition-map.test.js
@@ -0,0 +1,15 @@
+import * as Icons from "react-icons/wi";
+import { describe, expect, it } from "vitest";
+
+import mapIcon from "./owm-condition-map";
+
+describe("utils/weather/owm-condition-map", () => {
+ it("maps known condition codes to day/night icons", () => {
+ expect(mapIcon(804, "day")).toBe(Icons.WiCloudy);
+ expect(mapIcon(500, "night")).toBe(Icons.WiNightAltRain);
+ });
+
+ it("falls back to a default icon for unknown codes", () => {
+ expect(mapIcon(999999, "day")).toBe(Icons.WiDaySunny);
+ });
+});