test: finish utils coverage (contexts, kubernetes helpers, weather maps)

This commit is contained in:
shamoon
2026-02-04 09:33:08 -08:00
parent dfb25a2d61
commit 4c3d39709b
19 changed files with 636 additions and 19 deletions

View File

@@ -17,7 +17,7 @@ const getInitialColor = () => {
export const ColorContext = createContext(); export const ColorContext = createContext();
export function ColorProvider({ initialTheme, children }) { export function ColorProvider({ initialTheme, children }) {
const [color, setColor] = useState(getInitialColor); const [color, setColor] = useState(() => initialTheme ?? getInitialColor());
const rawSetColor = (rawColor) => { const rawSetColor = (rawColor) => {
const root = window.document.documentElement; const root = window.document.documentElement;
@@ -30,9 +30,10 @@ export function ColorProvider({ initialTheme, children }) {
lastColor = rawColor; lastColor = rawColor;
}; };
if (initialTheme) { useEffect(() => {
rawSetColor(initialTheme); if (initialTheme !== undefined) setColor(initialTheme ?? getInitialColor());
} // eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialTheme]);
useEffect(() => { useEffect(() => {
rawSetColor(color); rawSetColor(color);

View File

@@ -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 (
<div>
<div data-testid="value">{color}</div>
<button type="button" onClick={() => setColor("red")}>
red
</button>
</div>
);
}
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(
<ColorProvider>
<Reader />
</ColorProvider>,
);
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");
});
});

View File

@@ -1,13 +1,13 @@
import { createContext, useMemo, useState } from "react"; import { createContext, useEffect, useMemo, useState } from "react";
export const SettingsContext = createContext(); export const SettingsContext = createContext();
export function SettingsProvider({ initialSettings, children }) { export function SettingsProvider({ initialSettings, children }) {
const [settings, setSettings] = useState({}); const [settings, setSettings] = useState(() => initialSettings ?? {});
if (initialSettings) { useEffect(() => {
setSettings(initialSettings); if (initialSettings !== undefined) setSettings(initialSettings ?? {});
} }, [initialSettings]);
const value = useMemo(() => ({ settings, setSettings }), [settings]); const value = useMemo(() => ({ settings, setSettings }), [settings]);

View File

@@ -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 (
<div>
<div data-testid="value">{JSON.stringify(settings)}</div>
<button type="button" onClick={() => setSettings({ updated: true })}>
update
</button>
</div>
);
}
describe("utils/contexts/settings", () => {
it("provides initial settings and allows updates", () => {
render(
<SettingsProvider initialSettings={{ a: 1 }}>
<Reader />
</SettingsProvider>,
);
expect(screen.getByTestId("value")).toHaveTextContent('{"a":1}');
fireEvent.click(screen.getByRole("button", { name: "update" }));
expect(screen.getByTestId("value")).toHaveTextContent('{"updated":true}');
});
});

View File

@@ -1,13 +1,13 @@
import { createContext, useMemo, useState } from "react"; import { createContext, useEffect, useMemo, useState } from "react";
export const TabContext = createContext(); export const TabContext = createContext();
export function TabProvider({ initialTab, children }) { export function TabProvider({ initialTab, children }) {
const [activeTab, setActiveTab] = useState(false); const [activeTab, setActiveTab] = useState(() => initialTab ?? false);
if (initialTab) { useEffect(() => {
setActiveTab(initialTab); if (initialTab !== undefined) setActiveTab(initialTab ?? false);
} }, [initialTab]);
const value = useMemo(() => ({ activeTab, setActiveTab }), [activeTab]); const value = useMemo(() => ({ activeTab, setActiveTab }), [activeTab]);

View File

@@ -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 (
<div>
<div data-testid="value">{String(activeTab)}</div>
<button type="button" onClick={() => setActiveTab("next")}>
next
</button>
</div>
);
}
describe("utils/contexts/tab", () => {
it("provides initial tab and allows updates", () => {
render(
<TabProvider initialTab="first">
<Reader />
</TabProvider>,
);
expect(screen.getByTestId("value")).toHaveTextContent("first");
fireEvent.click(screen.getByRole("button", { name: "next" }));
expect(screen.getByTestId("value")).toHaveTextContent("next");
});
});

View File

@@ -19,7 +19,7 @@ const getInitialTheme = () => {
export const ThemeContext = createContext(); export const ThemeContext = createContext();
export function ThemeProvider({ initialTheme, children }) { export function ThemeProvider({ initialTheme, children }) {
const [theme, setTheme] = useState(getInitialTheme); const [theme, setTheme] = useState(() => initialTheme ?? getInitialTheme());
const rawSetTheme = (rawTheme) => { const rawSetTheme = (rawTheme) => {
const root = window.document.documentElement; const root = window.document.documentElement;
@@ -31,9 +31,10 @@ export function ThemeProvider({ initialTheme, children }) {
localStorage.setItem("theme-mode", rawTheme); localStorage.setItem("theme-mode", rawTheme);
}; };
if (initialTheme) { useEffect(() => {
rawSetTheme(initialTheme); if (initialTheme !== undefined) setTheme(initialTheme ?? getInitialTheme());
} // eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialTheme]);
useEffect(() => { useEffect(() => {
rawSetTheme(theme); rawSetTheme(theme);

View File

@@ -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 <div data-testid="value">{theme}</div>;
}
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(
<ThemeProvider>
<Reader />
</ThemeProvider>,
);
expect(screen.getByTestId("value")).toHaveTextContent("light");
await waitFor(() => expect(document.documentElement.classList.contains("light")).toBe(true));
expect(localStorage.getItem("theme-mode")).toBe("light");
});
});

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -20,7 +20,7 @@ export default async function listIngress() {
logger.debug(error); logger.debug(error);
return null; return null;
}); });
ingressList = ingressData.items; ingressList = ingressData?.items ?? [];
} }
return ingressList; return ingressList;
} }

View File

@@ -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();
});
});

View File

@@ -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");
});
});

View File

@@ -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();
});
});

View File

@@ -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);
});
});

View File

@@ -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),
}),
);
});
});

View File

@@ -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);
});
});

View File

@@ -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);
});
});

View File

@@ -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);
});
});