From 90cf136b8cf522eab55c551a33e6291624faa19d Mon Sep 17 00:00:00 2001 From: Jeremy Gallant <8975765+philon-@users.noreply.github.com> Date: Mon, 1 Sep 2025 03:30:11 +0200 Subject: [PATCH 1/2] Update test format (#821) * Lint tests, format JSON * Update test formatting --------- Co-authored-by: philon- --- packages/transport-http/src/transport.test.ts | 32 ++++++-- .../src/transport.test.ts | 20 ++--- packages/transport-node/src/transport.test.ts | 35 ++++----- .../DeleteMessagesDialog.test.tsx | 3 +- .../components/Dialog/RebootDialog.test.tsx | 77 ++++++++++--------- .../RefreshKeysDialog.test.tsx | 10 ++- .../useUnsafeRolesDialog.test.tsx | 2 +- .../Messages/TraceRoute.test.tsx | 3 +- .../components/UI/Checkbox/Checkbox.test.tsx | 13 +++- .../src/core/hooks/useFavoriteNode.test.ts | 2 +- .../web/src/core/hooks/useIgnoreNode.test.ts | 4 +- packages/web/src/core/hooks/useToast.test.tsx | 2 +- .../web/src/core/stores/utils/indexDB.test.ts | 6 +- 13 files changed, 120 insertions(+), 89 deletions(-) diff --git a/packages/transport-http/src/transport.test.ts b/packages/transport-http/src/transport.test.ts index f1712964..f375ea85 100644 --- a/packages/transport-http/src/transport.test.ts +++ b/packages/transport-http/src/transport.test.ts @@ -1,13 +1,23 @@ -import { describe, vi, expect, it, beforeEach, afterEach, type MockInstance } from "vitest"; +import { + afterEach, + beforeEach, + describe, + expect, + it, + type MockInstance, + vi, +} from "vitest"; import { runTransportContract } from "../../../tests/utils/transportContract"; import { TransportHTTP } from "./transport"; let abortTimeoutSpy: MockInstance | undefined; beforeEach(() => { - abortTimeoutSpy = vi.spyOn( + abortTimeoutSpy = vi + .spyOn( globalThis.AbortSignal as unknown as { timeout(ms: number): AbortSignal }, "timeout", - ).mockImplementation((ms: number) => { + ) + .mockImplementation((ms: number) => { const ctrl = new AbortController(); const abort = () => ctrl.abort(new DOMException("Timeout reached", "TimeoutError")); @@ -118,20 +128,28 @@ describe("TransportHTTP (contract)", () => { vi.unstubAllGlobals(); }, create: async () => { - (globalThis as unknown as { __http: ReturnType }).__http = stubFetch(); + ( + globalThis as unknown as { __http: ReturnType } + ).__http = stubFetch(); const transport = await TransportHTTP.create("127.0.0.1:80", false); await tickNextTimer(); return transport; }, pushIncoming: async (bytes) => { - (globalThis as unknown as { __http: ReturnType }).__http.pushIncoming(bytes); + ( + globalThis as unknown as { __http: ReturnType } + ).__http.pushIncoming(bytes); await tickNextTimer(); }, assertLastWritten: (bytes) => { - (globalThis as unknown as { __http: ReturnType }).__http.assertLastWritten(bytes); + ( + globalThis as unknown as { __http: ReturnType } + ).__http.assertLastWritten(bytes); }, triggerDisconnect: async () => { - (globalThis as unknown as { __http: ReturnType }).__http.forceReadErrorOnce(); + ( + globalThis as unknown as { __http: ReturnType } + ).__http.forceReadErrorOnce(); await tickNextTimer(); }, }); diff --git a/packages/transport-node-serial/src/transport.test.ts b/packages/transport-node-serial/src/transport.test.ts index ef4e4a4e..b4144401 100644 --- a/packages/transport-node-serial/src/transport.test.ts +++ b/packages/transport-node-serial/src/transport.test.ts @@ -1,7 +1,7 @@ -import { describe, vi, expect, beforeEach, afterEach, it } from "vitest"; import { Duplex } from "node:stream"; -import type { SerialPort } from "serialport"; import { Types, Utils } from "@meshtastic/core"; +import type { SerialPort } from "serialport"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { runTransportContract } from "../../../tests/utils/transportContract"; import { TransportNodeSerial } from "./transport"; @@ -112,21 +112,21 @@ describe("TransportNodeSerial (contract)", () => { return transport; }, pushIncoming: async (bytes) => { - (globalThis as unknown as { __fakePort: FakeSerialPort }).__fakePort.pushIncoming( - bytes, - ); + ( + globalThis as unknown as { __fakePort: FakeSerialPort } + ).__fakePort.pushIncoming(bytes); await Promise.resolve(); }, assertLastWritten: (bytes) => { - const port = - (globalThis as unknown as { __fakePort: FakeSerialPort }).__fakePort; + const port = (globalThis as unknown as { __fakePort: FakeSerialPort }) + .__fakePort; expect(port.lastWritten).toBeDefined(); expect(port.lastWritten).toEqual(bytes); }, triggerDisconnect: async () => { - (globalThis as unknown as { __fakePort: FakeSerialPort }).__fakePort.emitErrorOnce( - "test-disconnect", - ); + ( + globalThis as unknown as { __fakePort: FakeSerialPort } + ).__fakePort.emitErrorOnce("test-disconnect"); await Promise.resolve(); }, }); diff --git a/packages/transport-node/src/transport.test.ts b/packages/transport-node/src/transport.test.ts index 0e5f7d79..3519547d 100644 --- a/packages/transport-node/src/transport.test.ts +++ b/packages/transport-node/src/transport.test.ts @@ -1,9 +1,9 @@ -import { describe, vi, expect, beforeEach, afterEach, it } from "vitest"; -import { Duplex } from "node:stream"; import type { Socket } from "node:net"; +import { Duplex } from "node:stream"; +import { Types, Utils } from "@meshtastic/core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { runTransportContract } from "../../../tests/utils/transportContract"; import { TransportNode } from "./transport"; -import { Utils, Types } from "@meshtastic/core"; function isStatusEvent( out: Types.DeviceOutput | undefined, @@ -71,15 +71,13 @@ function stubCoreTransforms() { toDevice as unknown as typeof Utils.toDeviceStream, ); - vi - .spyOn(Utils, "fromDeviceStream") - .mockImplementation( - () => - fromDeviceFactory() as unknown as TransformStream< - Uint8Array, - Types.DeviceOutput - >, - ); + vi.spyOn(Utils, "fromDeviceStream").mockImplementation( + () => + fromDeviceFactory() as unknown as TransformStream< + Uint8Array, + Types.DeviceOutput + >, + ); return { restore: () => vi.restoreAllMocks(), @@ -112,9 +110,9 @@ describe("TransportNode (contract)", () => { return transport; }, pushIncoming: async (bytes) => { - (globalThis as unknown as { __nodeSock: FakeSocket }).__nodeSock.pushIncoming( - bytes, - ); + ( + globalThis as unknown as { __nodeSock: FakeSocket } + ).__nodeSock.pushIncoming(bytes); await Promise.resolve(); }, assertLastWritten: (bytes) => { @@ -124,9 +122,9 @@ describe("TransportNode (contract)", () => { expect(sock.lastWritten).toEqual(bytes); }, triggerDisconnect: async () => { - (globalThis as unknown as { __nodeSock: FakeSocket }).__nodeSock.emitErrorOnce( - "test-disconnect", - ); + ( + globalThis as unknown as { __nodeSock: FakeSocket } + ).__nodeSock.emitErrorOnce("test-disconnect"); await Promise.resolve(); }, }); @@ -182,5 +180,4 @@ describe("TransportNode (extras)", () => { reader.releaseLock(); await transport.disconnect(); }); - }); diff --git a/packages/web/src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.test.tsx b/packages/web/src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.test.tsx index 581783d8..2f7c83af 100644 --- a/packages/web/src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.test.tsx +++ b/packages/web/src/components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.test.tsx @@ -1,8 +1,7 @@ import { DeleteMessagesDialog } from "@components/Dialog/DeleteMessagesDialog/DeleteMessagesDialog.tsx"; +import { useMessageStore } from "@core/stores"; import { fireEvent, render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -// Ensure the path is correct for import -import { useMessageStore } from "@core/stores"; vi.mock("@core/stores", () => ({ useMessageStore: vi.fn(() => ({ diff --git a/packages/web/src/components/Dialog/RebootDialog.test.tsx b/packages/web/src/components/Dialog/RebootDialog.test.tsx index bf88be9c..edf4afce 100644 --- a/packages/web/src/components/Dialog/RebootDialog.test.tsx +++ b/packages/web/src/components/Dialog/RebootDialog.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, act } from "@testing-library/react"; +import { act, fireEvent, render, screen } from "@testing-library/react"; import type { ButtonHTMLAttributes, ClassAttributes, @@ -11,10 +11,12 @@ import { RebootDialog } from "./RebootDialog.tsx"; const rebootMock = vi.fn(); const rebootOtaMock = vi.fn(); -let mockConnection: { - rebootOta: (delay: number) => void, - reboot: (delay: number) => void - } | undefined = { +let mockConnection: + | { + rebootOta: (delay: number) => void; + reboot: (delay: number) => void; + } + | undefined = { reboot: rebootMock, rebootOta: rebootOtaMock, }; @@ -82,42 +84,44 @@ describe("RebootDialog", () => { expect( screen.getByRole("heading", { name: /reboot device/i, level: 1 }), ).toBeInTheDocument(); - expect(screen.getByRole("button", { name: /reboot now/i })).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /reboot now/i }), + ).toBeInTheDocument(); }); - it("calls correct reboot function based on OTA checkbox state", () => { - render( {}} />); + it("calls correct reboot function based on OTA checkbox state", () => { + render( {}} />); - // Schedule non-OTA reboot - act(() => { - fireEvent.click(screen.getByTestId("scheduleRebootBtn")); - }); - expect(rebootMock).toHaveBeenCalledWith(5); - expect(rebootOtaMock).not.toHaveBeenCalled(); + // Schedule non-OTA reboot + act(() => { + fireEvent.click(screen.getByTestId("scheduleRebootBtn")); + }); + expect(rebootMock).toHaveBeenCalledWith(5); + expect(rebootOtaMock).not.toHaveBeenCalled(); - rebootMock.mockClear(); - rebootOtaMock.mockClear(); + rebootMock.mockClear(); + rebootOtaMock.mockClear(); - // Cancel scheduled - act(() => { - fireEvent.click(screen.getByTestId("cancelRebootBtn")); - }); - expect(rebootMock).toHaveBeenCalledWith(-1); - expect(rebootOtaMock).not.toHaveBeenCalled(); + // Cancel scheduled + act(() => { + fireEvent.click(screen.getByTestId("cancelRebootBtn")); + }); + expect(rebootMock).toHaveBeenCalledWith(-1); + expect(rebootOtaMock).not.toHaveBeenCalled(); - rebootMock.mockClear(); - rebootOtaMock.mockClear(); + rebootMock.mockClear(); + rebootOtaMock.mockClear(); - // Schedule OTA reboot - act(() => { - fireEvent.click(screen.getByText(/reboot into ota mode/i)); - }); - act(() => { - fireEvent.click(screen.getByTestId("scheduleRebootBtn")); - }); - expect(rebootOtaMock).toHaveBeenCalledWith(5); - expect(rebootMock).not.toHaveBeenCalled(); + // Schedule OTA reboot + act(() => { + fireEvent.click(screen.getByText(/reboot into ota mode/i)); }); + act(() => { + fireEvent.click(screen.getByTestId("scheduleRebootBtn")); + }); + expect(rebootOtaMock).toHaveBeenCalledWith(5); + expect(rebootMock).not.toHaveBeenCalled(); + }); it("schedules a reboot with delay and calls rebootOta", async () => { const onOpenChangeMock = vi.fn(); @@ -132,7 +136,7 @@ describe("RebootDialog", () => { act(() => { fireEvent.click(screen.getByTestId("scheduleRebootBtn")); }); - + expect(rebootMock).toHaveBeenCalledWith(3); expect(screen.getByText(/reboot has been scheduled/i)).toBeInTheDocument(); @@ -142,7 +146,6 @@ describe("RebootDialog", () => { }); expect(onOpenChangeMock).toHaveBeenCalledWith(false); - }); it("triggers an instant reboot", async () => { @@ -196,6 +199,8 @@ describe("RebootDialog", () => { fireEvent.click(screen.getByRole("button", { name: /cancel/i })); }); expect(rebootMock).toHaveBeenCalledWith(-1); - expect(screen.queryByText(/reboot has been scheduled/i)).not.toBeInTheDocument(); + expect( + screen.queryByText(/reboot has been scheduled/i), + ).not.toBeInTheDocument(); }); }); diff --git a/packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.test.tsx b/packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.test.tsx index d9501fc6..7a18997b 100644 --- a/packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.test.tsx +++ b/packages/web/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.test.tsx @@ -1,11 +1,17 @@ -import { CurrentDeviceContext, useDeviceStore, useMessageStore } from "@core/stores"; +import { + CurrentDeviceContext, + useDeviceStore, + useMessageStore, +} from "@core/stores"; import { render } from "@testing-library/react"; import { afterEach, beforeEach, expect, test, vi } from "vitest"; import { RefreshKeysDialog } from "./RefreshKeysDialog.tsx"; import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts"; vi.mock("@core/stores", async () => { - const actual = (await vi.importActual("@core/stores")) as typeof import("@core/stores"); + const actual = (await vi.importActual( + "@core/stores", + )) as typeof import("@core/stores"); return { ...actual, useMessageStore: vi.fn(), diff --git a/packages/web/src/components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.test.tsx b/packages/web/src/components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.test.tsx index cc69ef71..4adcd091 100644 --- a/packages/web/src/components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.test.tsx +++ b/packages/web/src/components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.test.tsx @@ -38,7 +38,7 @@ const mockDevice = { vi.mock("@core/stores", () => ({ CurrentDeviceContext: { - _currentValue: { deviceId: 123 }, + _currentValue: { deviceId: 123 }, }, useDevice: () => ({ setDialogOpen: mockDevice.setDialogOpen, diff --git a/packages/web/src/components/PageComponents/Messages/TraceRoute.test.tsx b/packages/web/src/components/PageComponents/Messages/TraceRoute.test.tsx index b3a40da7..00db97d8 100644 --- a/packages/web/src/components/PageComponents/Messages/TraceRoute.test.tsx +++ b/packages/web/src/components/PageComponents/Messages/TraceRoute.test.tsx @@ -1,6 +1,6 @@ import { TraceRoute } from "@components/PageComponents/Messages/TraceRoute.tsx"; -import { mockNodeDBStore } from "@core/stores/nodeDBStore/nodeDBStore.mock.ts"; import { useNodeDB } from "@core/stores"; +import { mockNodeDBStore } from "@core/stores/nodeDBStore/nodeDBStore.mock.ts"; import { Protobuf } from "@meshtastic/core"; import { render, screen } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -64,7 +64,6 @@ describe("TraceRoute", () => { ]); beforeEach(() => { - vi.resetAllMocks(); vi.mocked(useNodeDB).mockReturnValue({ ...mockNodeDBStore, diff --git a/packages/web/src/components/UI/Checkbox/Checkbox.test.tsx b/packages/web/src/components/UI/Checkbox/Checkbox.test.tsx index 0d5bf934..399f097e 100644 --- a/packages/web/src/components/UI/Checkbox/Checkbox.test.tsx +++ b/packages/web/src/components/UI/Checkbox/Checkbox.test.tsx @@ -58,7 +58,9 @@ describe("Checkbox", () => { it("controlled: reflects external prop changes after onChange", () => { const onChange = vi.fn(); - const { rerender } = render(); + const { rerender } = render( + , + ); const checkbox = screen.getByRole("checkbox"); const presentation = screen.getByRole("presentation"); @@ -79,7 +81,9 @@ describe("Checkbox", () => { it("renders children inside the label", () => { render(Test Label); - expect(screen.getByTestId("label-component")).toHaveTextContent("Test Label"); + expect(screen.getByTestId("label-component")).toHaveTextContent( + "Test Label", + ); }); it("applies custom className to wrapper label", () => { @@ -112,7 +116,10 @@ describe("Checkbox", () => { it("passes through additional props to the input", () => { render(); - expect(screen.getByRole("checkbox")).toHaveAttribute("data-testid", "extra-prop"); + expect(screen.getByRole("checkbox")).toHaveAttribute( + "data-testid", + "extra-prop", + ); }); it("uncontrolled: toggles checked state when clicking the visual box", () => { diff --git a/packages/web/src/core/hooks/useFavoriteNode.test.ts b/packages/web/src/core/hooks/useFavoriteNode.test.ts index 76063533..d133fbac 100644 --- a/packages/web/src/core/hooks/useFavoriteNode.test.ts +++ b/packages/web/src/core/hooks/useFavoriteNode.test.ts @@ -18,7 +18,7 @@ const mockSendAdminMessage = vi.fn(); vi.mock("@core/stores", () => ({ CurrentDeviceContext: { - _currentValue: { deviceId: 1234 }, + _currentValue: { deviceId: 1234 }, }, useNodeDB: () => ({ updateFavorite: mockUpdateFavorite, diff --git a/packages/web/src/core/hooks/useIgnoreNode.test.ts b/packages/web/src/core/hooks/useIgnoreNode.test.ts index deb5b143..5f271d21 100644 --- a/packages/web/src/core/hooks/useIgnoreNode.test.ts +++ b/packages/web/src/core/hooks/useIgnoreNode.test.ts @@ -17,8 +17,8 @@ const mockToast = vi.fn(); const mockSendAdminMessage = vi.fn(); vi.mock("@core/stores", () => ({ - CurrentDeviceContext: { - _currentValue: { deviceId: 1234 }, + CurrentDeviceContext: { + _currentValue: { deviceId: 1234 }, }, useNodeDB: () => ({ updateIgnore: mockUpdateIgnore, diff --git a/packages/web/src/core/hooks/useToast.test.tsx b/packages/web/src/core/hooks/useToast.test.tsx index 38c33d76..aa6254c4 100644 --- a/packages/web/src/core/hooks/useToast.test.tsx +++ b/packages/web/src/core/hooks/useToast.test.tsx @@ -28,7 +28,7 @@ describe("useToast", () => { vi.runAllTimers(); }); - const toast = result.current.toasts[0]; + const toast = result.current.toasts[0]!; expect(result.current.toasts.length).toBe(1); expect(toast.title).toBe("Backup Reminder"); expect(toast.description).toBe("Don't forget to backup!"); diff --git a/packages/web/src/core/stores/utils/indexDB.test.ts b/packages/web/src/core/stores/utils/indexDB.test.ts index 14abe329..94c143d9 100644 --- a/packages/web/src/core/stores/utils/indexDB.test.ts +++ b/packages/web/src/core/stores/utils/indexDB.test.ts @@ -1,5 +1,5 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; import * as idb from "idb-keyval"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { createStorage } from "./indexDB"; type PersistStorage = ReturnType>; @@ -109,7 +109,7 @@ describe("indexDB.ts persistence (steps 1–5)", () => { const out = await roundTrip({ state: { m }, version: 0 }); const m2 = out!.state.m as Map; expect(m2 instanceof Map).toBe(true); - + const got = m2.get(42)!; expect(got.key instanceof Uint8Array).toBe(true); expect(Array.from(got.key)).toEqual([7, 8]); @@ -155,4 +155,4 @@ describe("indexDB.ts persistence (steps 1–5)", () => { expect(m2 instanceof Map).toBe(true); expect(m2.get(1)).toBe("x"); }); -}); \ No newline at end of file +}); From 01fa030ef937299e3fc295d9d3b6c4ef1aed6474 Mon Sep 17 00:00:00 2001 From: Jeremy Gallant <8975765+philon-@users.noreply.github.com> Date: Mon, 1 Sep 2025 03:44:22 +0200 Subject: [PATCH 2/2] Fix default filter behaviour (#820) Co-authored-by: philon- --- .../generic/Filter/FilterControl.tsx | 231 +++++++++--------- .../generic/Filter/useFilterNode.test.ts | 52 ++++ .../generic/Filter/useFilterNode.ts | 10 +- 3 files changed, 176 insertions(+), 117 deletions(-) diff --git a/packages/web/src/components/generic/Filter/FilterControl.tsx b/packages/web/src/components/generic/Filter/FilterControl.tsx index 3eb282f7..c2eb6cf2 100644 --- a/packages/web/src/components/generic/Filter/FilterControl.tsx +++ b/packages/web/src/components/generic/Filter/FilterControl.tsx @@ -16,20 +16,19 @@ import { import { cn } from "@core/utils/cn.ts"; import { debounce } from "@core/utils/debounce.ts"; import { Protobuf } from "@meshtastic/core"; -import type { TFunction } from "i18next"; import { FunnelIcon } from "lucide-react"; import { type ComponentProps, type ReactNode, useCallback, useEffect, + useMemo, useRef, useState, } from "react"; import { useTranslation } from "react-i18next"; const DEBOUNCE_DELAY_MS = 250; -const BATTERY_STATUS_PLUGGED_IN_VALUE = 101; type PopoverContentProps = ComponentProps; @@ -47,88 +46,57 @@ interface FilterControlProps { children?: ReactNode; } -interface HopsLabelProps { - hopsAway: number[]; - t: TFunction<"ui", undefined>; +interface RangeLabelContentProps { + range: [number, number]; + defaultRange: [number, number]; + format?: (ts: number) => ReactNode; + initialLabel?: ReactNode; + customLabel?: { start?: string; end?: string }; } -function HopsLabelContent({ hopsAway, t }: HopsLabelProps) { - const startHops = hopsAway[0]; - const endHops = hopsAway[1]; - return ( - <> - {t("hops.text", { - value: startHops === 0 ? t("hops.direct") : startHops, - })} - {startHops !== endHops ? ` — ${endHops}` : ""} - - ); -} - -interface LastHeardLabelProps { - lastHeardRange: number[]; - defaultMaxLastHeard: number; - formatTS: (ts: number) => ReactNode; - t: TFunction<"ui", undefined>; -} -function LastHeardLabelContent({ - lastHeardRange, - defaultMaxLastHeard, - formatTS, - t, -}: LastHeardLabelProps) { - const [start, end] = lastHeardRange; - return ( - <> - {t("lastHeard.labelText", { value: "" })} -
- {start === 0 ? ( - t("lastHeard.nowLabel") - ) : ( - <> - {start === defaultMaxLastHeard && ">"} - {formatTS(start)} - - )} - {start !== end && ( - <> - {" — "} - {end === defaultMaxLastHeard && ">"} - {formatTS(end)} - - )} - - ); -} +function RangeLabelContent({ + range, + defaultRange, + format, + initialLabel, + customLabel, +}: RangeLabelContentProps) { + const [start, end] = range; + const [min, max] = defaultRange; + const unequal = start !== end; -interface BatteryLevelLabelProps { - batteryLevelRange: (number | undefined)[]; - t: TFunction<"ui", undefined>; -} -function BatteryLevelLabelContent({ - batteryLevelRange, - t, -}: BatteryLevelLabelProps) { - const [start, end] = batteryLevelRange; - - const formatBatteryValue = (value: number | undefined) => { - if (value === undefined) { - return ""; - } - return value === BATTERY_STATUS_PLUGGED_IN_VALUE - ? t("batteryStatus.pluggedIn") - : `${value}%`; - }; + const fmtStart = format ? format(start) : start; + const fmtEnd = format ? format(end) : end; return ( <> - {t("batteryLevel.labelText", { - value: formatBatteryValue(start), - })} - {start !== end && typeof end !== "undefined" && ( + {initialLabel} + {start === min + ? (customLabel?.start ?? ( + <> + {"<"} + {fmtStart} + + )) + : start === max + ? (customLabel?.end ?? ( + <> + {">"} + {fmtEnd} + + )) + : fmtStart} + {unequal && ( <> - {" – "} - {formatBatteryValue(end)} + {" — "} + {end === max + ? (customLabel?.end ?? ( + <> + {">"} + {fmtEnd} + + )) + : fmtEnd} )} @@ -190,18 +158,20 @@ export function FilterControl({ ); const handleBoolChange = useCallback( - (key: K, value: string | boolean) => { - const typedValue = - value === true || value === "true" - ? true - : value === false || value === "false" - ? false - : undefined; + ( + key: K, + value: string | boolean | undefined, + ) => { + let typedValue: boolean | undefined; + if (value === true || value === "true") { + typedValue = true; + } else if (value === false || value === "false") { + typedValue = false; + } else { + typedValue = undefined; + } - setFilterState((prev) => ({ - ...prev, - [key]: typedValue, - })); + setFilterState((prev) => ({ ...prev, [key]: typedValue })); }, [setFilterState], ); @@ -220,6 +190,21 @@ export function FilterControl({ [], ); + const roleOptions = useMemo( + () => + Object.values(Protobuf.Config.Config_DeviceConfig_Role).filter( + (v): v is number => typeof v === "number", + ), + [], + ); + const hwModelOptions = useMemo( + () => + Object.values(Protobuf.Mesh.HardwareModel).filter( + (v): v is number => typeof v === "number", + ), + [], + ); + return ( @@ -229,9 +214,8 @@ export function FilterControl({ "rounded", "text-slate-600 hover:text-slate-700 bg-slate-100 hover:bg-slate-200 active:bg-slate-300", "dark:text-slate-400 hover:dark:text-slate-400 dark:bg-slate-700 hover:dark:bg-slate-800 dark:active:bg-slate-950", - isDirty - ? "text-slate-100 dark:text-slate-300 bg-green-600 dark:bg-green-700 hover:bg-green-700 dark:hover:bg-green-800 hover:text-slate-200 dark:hover:text-slate-300 active:bg-green-800 dark:active:bg-green-900" - : "", + isDirty && + "text-slate-100 dark:text-slate-300 bg-green-600 dark:bg-green-700 hover:bg-green-700 dark:hover:bg-green-800 hover:text-slate-200 dark:hover:text-slate-300 active:bg-green-800 dark:active:bg-green-900", parameters?.popoverTriggerClassName, )} aria-label={t("filter.label")} @@ -269,30 +253,31 @@ export function FilterControl({ )} } /> } /> @@ -314,11 +299,17 @@ export function FilterControl({ + } /> } /> + } /> @@ -361,11 +364,11 @@ export function FilterControl({ filterKey="role" filterState={filterState} setFilterState={setFilterState} - options={Object.values( - Protobuf.Config.Config_DeviceConfig_Role, - ).filter((v): v is number => typeof v === "number")} + options={roleOptions} getLabel={(val) => - formatEnumLabel(Protobuf.Config.Config_DeviceConfig_Role[val]) + formatEnumLabel( + Protobuf.Config.Config_DeviceConfig_Role[val] ?? "UNSET", + ) } /> @@ -375,11 +378,9 @@ export function FilterControl({ filterKey="hwModel" filterState={filterState} setFilterState={setFilterState} - options={Object.values(Protobuf.Mesh.HardwareModel).filter( - (v): v is number => typeof v === "number", - )} + options={hwModelOptions} getLabel={(val) => - formatEnumLabel(Protobuf.Mesh.HardwareModel[val]) + formatEnumLabel(Protobuf.Mesh.HardwareModel[val] ?? "UNKNOWN") } /> diff --git a/packages/web/src/components/generic/Filter/useFilterNode.test.ts b/packages/web/src/components/generic/Filter/useFilterNode.test.ts index a7b48f04..439fd357 100644 --- a/packages/web/src/components/generic/Filter/useFilterNode.test.ts +++ b/packages/web/src/components/generic/Filter/useFilterNode.test.ts @@ -180,4 +180,56 @@ describe("useFilterNode", () => { expect(isFilterDirty(modified)).toBe(true); }); }); + + describe("default-boundary semantics", () => { + const node = createMockNode(); + + it("lastHeard: end at default max means 'any age'", () => { + const { + lastHeard: [lastHeardMin, lastHeardMax], + } = defaultFilterValues; + const veryOld = { ...node, lastHeard: lastHeardMax + 1 }; // older than slider max + expect( + nodeFilter(veryOld, { lastHeard: [lastHeardMin, lastHeardMax] }), + ).toBe(true); // open upper + expect( + nodeFilter(veryOld, { lastHeard: [lastHeardMin, lastHeardMax - 1] }), + ).toBe(false); // now bounded + }); + + it("snr: max at default means no upper bound", () => { + const { + snr: [snrMin, snrMax], + } = defaultFilterValues; + const hiSnr = { ...node, snr: snrMax + 1 }; // above slider max + expect(nodeFilter(hiSnr, { snr: [snrMin, snrMax] })).toBe(true); // open upper + expect(nodeFilter(hiSnr, { snr: [snrMin, snrMax - 1] })).toBe(false); // bounded + }); + + it("snr: min at default means no lower bound", () => { + const { + snr: [snrMin, snrMax], + } = defaultFilterValues; + const loSnr = { ...node, snr: snrMin - 1 }; // below slider min + expect(nodeFilter(loSnr, { snr: [snrMin, snrMax] })).toBe(true); // open lower + expect(nodeFilter(loSnr, { snr: [snrMin + 1, snrMax] })).toBe(false); // bounded + }); + + it("voltage: max at default means no upper bound", () => { + const { + voltage: [voltageMin, voltageMax], + } = defaultFilterValues; + const hiV = { + ...node, + deviceMetrics: { + ...node.deviceMetrics!, + voltage: voltageMax + 1, + }, + } satisfies Protobuf.Mesh.NodeInfo; + expect(nodeFilter(hiV, { voltage: [voltageMin, voltageMax] })).toBe(true); // open upper + expect( + nodeFilter(hiV, { voltage: [voltageMin, voltageMax - 0.01] }), + ).toBe(false); // bounded + }); + }); }); diff --git a/packages/web/src/components/generic/Filter/useFilterNode.ts b/packages/web/src/components/generic/Filter/useFilterNode.ts index eade279a..275c67b1 100644 --- a/packages/web/src/components/generic/Filter/useFilterNode.ts +++ b/packages/web/src/components/generic/Filter/useFilterNode.ts @@ -127,7 +127,12 @@ export function useFilterNode() { } const snr = node.snr ?? -20; - if (snr < filterState.snr[0] || snr > filterState.snr[1]) { + if ( + (snr < filterState.snr[0] && + filterState.snr[0] !== defaultFilterValues.snr[0]) || + (snr > filterState.snr[1] && + filterState.snr[1] !== defaultFilterValues.snr[1]) + ) { return false; } @@ -158,7 +163,8 @@ export function useFilterNode() { const voltage = node.deviceMetrics?.voltage ?? 0; if ( voltage < filterState.voltage[0] || - voltage > filterState.voltage[1] + (voltage > filterState.voltage[1] && + filterState.voltage[1] !== defaultFilterValues.voltage[1]) ) { return false; }