Browse Source
* fixed tsc errors * fixed tsc errors * fixed tsc errors * fixing tsc errors * fixing more tsc errors * fixing more tsc errors * fixed tsc errors * fixing tsc errors * fixing PR issues * commented out tsc check * completing tsc fixes * updating lockfile * removed react-hookspull/650/head
committed by
GitHub
59 changed files with 1140 additions and 2799 deletions
File diff suppressed because it is too large
@ -13,6 +13,7 @@ |
|||||
"dev": "deno task dev:ui", |
"dev": "deno task dev:ui", |
||||
"dev:ui": "VITE_APP_VERSION=development deno run -A npm:vite dev", |
"dev:ui": "VITE_APP_VERSION=development deno run -A npm:vite dev", |
||||
"test": "deno run -A npm:vitest", |
"test": "deno run -A npm:vitest", |
||||
|
"check": "deno check", |
||||
"preview": "deno run -A npm:vite preview", |
"preview": "deno run -A npm:vite preview", |
||||
"package": "gzipper c -i html,js,css,png,ico,svg,webmanifest,txt dist dist/output && tar -cvf dist/build.tar -C ./dist/output/ ." |
"package": "gzipper c -i html,js,css,png,ico,svg,webmanifest,txt dist dist/output && tar -cvf dist/build.tar -C ./dist/output/ ." |
||||
}, |
}, |
||||
@ -35,10 +36,11 @@ |
|||||
"homepage": "https://meshtastic.org", |
"homepage": "https://meshtastic.org", |
||||
"dependencies": { |
"dependencies": { |
||||
"@bufbuild/protobuf": "^2.2.5", |
"@bufbuild/protobuf": "^2.2.5", |
||||
"@meshtastic/core": "npm:@jsr/[email protected]", |
"@meshtastic/core": "npm:@jsr/[email protected]", |
||||
"@meshtastic/transport-http": "npm:@jsr/[email protected]", |
"@meshtastic/js": "npm:@jsr/[email protected]", |
||||
"@meshtastic/transport-web-bluetooth": "npm:@jsr/[email protected]", |
"@meshtastic/transport-http": "npm:@jsr/meshtastic__transport-http", |
||||
"@meshtastic/transport-web-serial": "npm:@jsr/[email protected]", |
"@meshtastic/transport-web-bluetooth": "npm:@jsr/meshtastic__transport-web-bluetooth", |
||||
|
"@meshtastic/transport-web-serial": "npm:@jsr/meshtastic__transport-web-serial", |
||||
"@noble/curves": "^1.9.0", |
"@noble/curves": "^1.9.0", |
||||
"@radix-ui/react-accordion": "^1.2.8", |
"@radix-ui/react-accordion": "^1.2.8", |
||||
"@radix-ui/react-checkbox": "^1.2.3", |
"@radix-ui/react-checkbox": "^1.2.3", |
||||
@ -60,6 +62,7 @@ |
|||||
"@tanstack/react-router-devtools": "^1.120.16", |
"@tanstack/react-router-devtools": "^1.120.16", |
||||
"@tanstack/router-devtools": "^1.120.15", |
"@tanstack/router-devtools": "^1.120.15", |
||||
"@turf/turf": "^7.2.0", |
"@turf/turf": "^7.2.0", |
||||
|
"@types/web-bluetooth": "^0.0.21", |
||||
"base64-js": "^1.5.1", |
"base64-js": "^1.5.1", |
||||
"class-variance-authority": "^0.7.1", |
"class-variance-authority": "^0.7.1", |
||||
"clsx": "^2.1.1", |
"clsx": "^2.1.1", |
||||
@ -81,10 +84,8 @@ |
|||||
"react-map-gl": "8.0.4", |
"react-map-gl": "8.0.4", |
||||
"react-qrcode-logo": "^3.0.0", |
"react-qrcode-logo": "^3.0.0", |
||||
"rfc4648": "^1.5.4", |
"rfc4648": "^1.5.4", |
||||
"vite-plugin-i18n-ally": "^6.0.1", |
"zod": "^3.25.62", |
||||
"vite-plugin-node-polyfills": "^0.23.0", |
"zustand": "5.0.5" |
||||
"zod": "^3.25.0", |
|
||||
"zustand": "5.0.4" |
|
||||
}, |
}, |
||||
"devDependencies": { |
"devDependencies": { |
||||
"@tailwindcss/postcss": "^4.1.5", |
"@tailwindcss/postcss": "^4.1.5", |
||||
@ -93,13 +94,12 @@ |
|||||
"@testing-library/react": "^16.3.0", |
"@testing-library/react": "^16.3.0", |
||||
"@testing-library/user-event": "^14.6.1", |
"@testing-library/user-event": "^14.6.1", |
||||
"@types/chrome": "^0.0.318", |
"@types/chrome": "^0.0.318", |
||||
"@types/js-cookie": "^3.0.6", |
|
||||
"@types/node": "^22.15.3", |
"@types/node": "^22.15.3", |
||||
"@types/react": "^19.1.2", |
"@types/react": "^19.1.2", |
||||
"@types/react-dom": "^19.1.3", |
"@types/react-dom": "^19.1.3", |
||||
"@types/serviceworker": "^0.0.133", |
"@types/serviceworker": "^0.0.133", |
||||
|
"@types/js-cookie": "^3.0.6", |
||||
"@types/w3c-web-serial": "^1.0.8", |
"@types/w3c-web-serial": "^1.0.8", |
||||
"@types/web-bluetooth": "^0.0.21", |
|
||||
"@vitejs/plugin-react": "^4.4.1", |
"@vitejs/plugin-react": "^4.4.1", |
||||
"autoprefixer": "^10.4.21", |
"autoprefixer": "^10.4.21", |
||||
"gzipper": "^8.2.1", |
"gzipper": "^8.2.1", |
||||
|
|||||
@ -1,149 +0,0 @@ |
|||||
import { beforeEach, describe, expect, it, vi } from "vitest"; |
|
||||
import { render, screen } from "@testing-library/react"; |
|
||||
import { NodeDetailsDialog } from "@components/Dialog/NodeDetailsDialog/NodeDetailsDialog.tsx"; |
|
||||
import { useDevice } from "@core/stores/deviceStore.ts"; |
|
||||
import { useAppStore } from "@core/stores/appStore.ts"; |
|
||||
import type { Protobuf } from "@meshtastic/core"; |
|
||||
|
|
||||
vi.mock("@core/stores/deviceStore"); |
|
||||
vi.mock("@core/stores/appStore"); |
|
||||
|
|
||||
const mockUseDevice = vi.mocked(useDevice); |
|
||||
const mockUseAppStore = vi.mocked(useAppStore); |
|
||||
|
|
||||
vi.mock("@tanstack/react-router", () => ({ |
|
||||
useNavigate: vi.fn(), |
|
||||
})); |
|
||||
|
|
||||
describe("NodeDetailsDialog", () => { |
|
||||
const mockNode = { |
|
||||
num: 1234, |
|
||||
user: { |
|
||||
longName: "Test Node", |
|
||||
shortName: "TN", |
|
||||
hwModel: 1, |
|
||||
role: 1, |
|
||||
}, |
|
||||
lastHeard: 1697500000, |
|
||||
position: { |
|
||||
latitudeI: 450000000, |
|
||||
longitudeI: -750000000, |
|
||||
altitude: 200, |
|
||||
}, |
|
||||
deviceMetrics: { |
|
||||
airUtilTx: 50.123, |
|
||||
channelUtilization: 75.456, |
|
||||
batteryLevel: 88.789, |
|
||||
voltage: 4.2, |
|
||||
uptimeSeconds: 3600, |
|
||||
}, |
|
||||
} as unknown as Protobuf.Mesh.NodeInfo; |
|
||||
|
|
||||
beforeEach(() => { |
|
||||
vi.resetAllMocks(); |
|
||||
|
|
||||
mockUseDevice.mockReturnValue({ |
|
||||
getNode: (nodeNum: number) => { |
|
||||
if (nodeNum === 1234) { |
|
||||
return mockNode; |
|
||||
} |
|
||||
return undefined; |
|
||||
}, |
|
||||
}); |
|
||||
|
|
||||
mockUseAppStore.mockReturnValue({ |
|
||||
nodeNumDetails: 1234, |
|
||||
}); |
|
||||
}); |
|
||||
|
|
||||
it("renders node details correctly", () => { |
|
||||
render(<NodeDetailsDialog open onOpenChange={() => {}} />); |
|
||||
|
|
||||
expect(screen.getByText(/Node Details for Test Node \(TN\)/i)) |
|
||||
.toBeInTheDocument(); |
|
||||
|
|
||||
expect(screen.getByText("Node Number: 1234")).toBeInTheDocument(); |
|
||||
expect(screen.getByText(/Node Hex: !/i)).toBeInTheDocument(); |
|
||||
expect(screen.getByText(/Last Heard:/i)).toBeInTheDocument(); |
|
||||
|
|
||||
expect(screen.getByText(/Coordinates:/i)).toBeInTheDocument(); |
|
||||
const link = screen.getByRole("link", { name: /^45, -75$/ }); |
|
||||
|
|
||||
expect(link).toBeInTheDocument(); |
|
||||
expect(link).toHaveAttribute( |
|
||||
"href", |
|
||||
expect.stringContaining("openstreetmap.org"), |
|
||||
); |
|
||||
expect(screen.getByText(/Altitude: 200m/i)).toBeInTheDocument(); |
|
||||
|
|
||||
expect(screen.getByText(/Air TX utilization: 50.12%/i)).toBeInTheDocument(); |
|
||||
expect(screen.getByText(/Channel utilization: 75.46%/i)) |
|
||||
.toBeInTheDocument(); |
|
||||
expect(screen.getByText(/Battery level: 88.79%/i)).toBeInTheDocument(); |
|
||||
expect(screen.getByText(/Voltage: 4.20V/i)).toBeInTheDocument(); |
|
||||
expect(screen.getByText(/Uptime:/i)).toBeInTheDocument(); |
|
||||
|
|
||||
expect(screen.getByText(/All Raw Metrics:/i)).toBeInTheDocument(); |
|
||||
}); |
|
||||
|
|
||||
it("renders null if node is undefined", () => { |
|
||||
const requestedNodeNum = 5678; |
|
||||
|
|
||||
mockUseAppStore.mockReturnValue({ |
|
||||
nodeNumDetails: requestedNodeNum, |
|
||||
}); |
|
||||
|
|
||||
mockUseDevice.mockReturnValue({ |
|
||||
getNode: (nodeNum: number) => { |
|
||||
if (nodeNum === requestedNodeNum) { |
|
||||
return undefined; |
|
||||
} |
|
||||
if (nodeNum === 1234) { |
|
||||
return mockNode; |
|
||||
} |
|
||||
return undefined; |
|
||||
}, |
|
||||
}); |
|
||||
|
|
||||
const { container } = render( |
|
||||
<NodeDetailsDialog open onOpenChange={() => {}} />, |
|
||||
); |
|
||||
|
|
||||
expect(container.firstChild).toBeNull(); |
|
||||
expect(screen.queryByText(/Node Details for/i)).not.toBeInTheDocument(); |
|
||||
}); |
|
||||
|
|
||||
it("renders correctly when position is missing", () => { |
|
||||
const nodeWithoutPosition = { ...mockNode, position: undefined }; |
|
||||
mockUseDevice.mockReturnValue({ getNode: () => nodeWithoutPosition }); |
|
||||
mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234 }); |
|
||||
|
|
||||
render(<NodeDetailsDialog open onOpenChange={() => {}} />); |
|
||||
|
|
||||
expect(screen.queryByText(/Coordinates:/i)).not.toBeInTheDocument(); |
|
||||
expect(screen.queryByText(/Altitude:/i)).not.toBeInTheDocument(); |
|
||||
expect(screen.getByText(/Node Details for Test Node/i)).toBeInTheDocument(); |
|
||||
}); |
|
||||
|
|
||||
it("renders correctly when deviceMetrics are missing", () => { |
|
||||
const nodeWithoutMetrics = { ...mockNode, deviceMetrics: undefined }; |
|
||||
mockUseDevice.mockReturnValue({ getNode: () => nodeWithoutMetrics }); |
|
||||
mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234 }); |
|
||||
|
|
||||
render(<NodeDetailsDialog open onOpenChange={() => {}} />); |
|
||||
|
|
||||
expect(screen.queryByText(/Device Metrics:/i)).not.toBeInTheDocument(); |
|
||||
expect(screen.queryByText(/Air TX utilization:/i)).not.toBeInTheDocument(); |
|
||||
expect(screen.getByText(/Node Details for Test Node/i)).toBeInTheDocument(); |
|
||||
}); |
|
||||
|
|
||||
it("renders 'Never' for lastHeard when timestamp is 0", () => { |
|
||||
const nodeNeverHeard = { ...mockNode, lastHeard: 0 }; |
|
||||
mockUseDevice.mockReturnValue({ getNode: () => nodeNeverHeard }); |
|
||||
mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234 }); |
|
||||
|
|
||||
render(<NodeDetailsDialog open onOpenChange={() => {}} />); |
|
||||
|
|
||||
expect(screen.getByText(/Last Heard: Never/i)).toBeInTheDocument(); |
|
||||
}); |
|
||||
}); |
|
||||
@ -1,86 +0,0 @@ |
|||||
import { act, renderHook } from "@testing-library/react"; |
|
||||
import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts"; |
|
||||
import { beforeEach, describe, expect, it, Mock, vi } from "vitest"; |
|
||||
import { useDevice } from "@core/stores/deviceStore.ts"; |
|
||||
import { useMessageStore } from "@core/stores/messageStore/index.ts"; |
|
||||
|
|
||||
vi.mock("@core/stores/messageStore", () => ({ |
|
||||
useMessageStore: vi.fn(() => ({ activeChat: "chat-123" })), |
|
||||
})); |
|
||||
vi.mock("@core/stores/deviceStore", () => ({ |
|
||||
useDevice: vi.fn(() => ({ |
|
||||
removeNode: vi.fn(), |
|
||||
setDialogOpen: vi.fn(), |
|
||||
getNodeError: vi.fn(), |
|
||||
clearNodeError: vi.fn(), |
|
||||
})), |
|
||||
})); |
|
||||
|
|
||||
describe("useRefreshKeysDialog Hook", () => { |
|
||||
let removeNodeMock: Mock; |
|
||||
let setDialogOpenMock: Mock; |
|
||||
let getNodeErrorMock: Mock; |
|
||||
let clearNodeErrorMock: Mock; |
|
||||
|
|
||||
beforeEach(() => { |
|
||||
vi.clearAllMocks(); |
|
||||
|
|
||||
removeNodeMock = vi.fn(); |
|
||||
setDialogOpenMock = vi.fn(); |
|
||||
getNodeErrorMock = vi.fn().mockReturnValue(undefined); |
|
||||
clearNodeErrorMock = vi.fn(); |
|
||||
|
|
||||
vi.mocked(useDevice).mockReturnValue({ |
|
||||
removeNode: removeNodeMock, |
|
||||
setDialogOpen: setDialogOpenMock, |
|
||||
getNodeError: getNodeErrorMock, |
|
||||
clearNodeError: clearNodeErrorMock, |
|
||||
}); |
|
||||
|
|
||||
vi.mocked(useMessageStore).mockReturnValue({ |
|
||||
activeChat: "chat-123", |
|
||||
}); |
|
||||
}); |
|
||||
|
|
||||
it("handleNodeRemove should remove the node and update dialog if there is an error", () => { |
|
||||
getNodeErrorMock.mockReturnValue({ node: "node-abc" }); |
|
||||
|
|
||||
const { result } = renderHook(() => useRefreshKeysDialog()); |
|
||||
act(() => { |
|
||||
result.current.handleNodeRemove(); |
|
||||
}); |
|
||||
|
|
||||
expect(getNodeErrorMock).toHaveBeenCalledTimes(1); |
|
||||
expect(getNodeErrorMock).toHaveBeenCalledWith("chat-123"); |
|
||||
expect(clearNodeErrorMock).toHaveBeenCalledTimes(1); |
|
||||
expect(clearNodeErrorMock).toHaveBeenCalledWith("chat-123"); |
|
||||
expect(removeNodeMock).toHaveBeenCalledTimes(1); |
|
||||
expect(removeNodeMock).toHaveBeenCalledWith("node-abc"); |
|
||||
expect(setDialogOpenMock).toHaveBeenCalledTimes(1); |
|
||||
expect(setDialogOpenMock).toHaveBeenCalledWith("refreshKeys", false); |
|
||||
}); |
|
||||
|
|
||||
it("handleNodeRemove should do nothing if there is no error", () => { |
|
||||
const { result } = renderHook(() => useRefreshKeysDialog()); |
|
||||
act(() => { |
|
||||
result.current.handleNodeRemove(); |
|
||||
}); |
|
||||
|
|
||||
expect(getNodeErrorMock).toHaveBeenCalledTimes(1); |
|
||||
expect(getNodeErrorMock).toHaveBeenCalledWith("chat-123"); |
|
||||
expect(clearNodeErrorMock).not.toHaveBeenCalled(); |
|
||||
expect(removeNodeMock).not.toHaveBeenCalled(); |
|
||||
expect(setDialogOpenMock).not.toHaveBeenCalled(); |
|
||||
}); |
|
||||
|
|
||||
it("handleCloseDialog should close the dialog", () => { |
|
||||
const { result } = renderHook(() => useRefreshKeysDialog()); |
|
||||
|
|
||||
act(() => { |
|
||||
result.current.handleCloseDialog(); |
|
||||
}); |
|
||||
|
|
||||
expect(setDialogOpenMock).toHaveBeenCalledTimes(1); |
|
||||
expect(setDialogOpenMock).toHaveBeenCalledWith("refreshKeys", false); |
|
||||
}); |
|
||||
}); |
|
||||
@ -1,134 +0,0 @@ |
|||||
import { fireEvent, render, screen } from "@testing-library/react"; |
|
||||
import { describe, expect, it, vi } from "vitest"; |
|
||||
import { UnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/UnsafeRolesDialog.tsx"; |
|
||||
import { |
|
||||
createMemoryHistory, |
|
||||
createRootRoute, |
|
||||
createRouter, |
|
||||
RouterProvider, |
|
||||
} from "@tanstack/react-router"; |
|
||||
import { eventBus } from "@core/utils/eventBus.ts"; |
|
||||
import { DeviceWrapper } from "@app/DeviceWrapper.tsx"; |
|
||||
|
|
||||
const rootRoute = createRootRoute(); |
|
||||
|
|
||||
describe.skip("UnsafeRolesDialog", () => { |
|
||||
const mockDevice = { |
|
||||
setDialogOpen: vi.fn(), |
|
||||
}; |
|
||||
|
|
||||
const renderWithProviders = (ui: React.ReactNode) => { |
|
||||
const testRouter = createRouter({ |
|
||||
routeTree: rootRoute, |
|
||||
history: createMemoryHistory(), |
|
||||
}); |
|
||||
|
|
||||
return render( |
|
||||
<RouterProvider router={testRouter}> |
|
||||
<DeviceWrapper device={mockDevice}> |
|
||||
{ui} |
|
||||
</DeviceWrapper> |
|
||||
</RouterProvider>, |
|
||||
); |
|
||||
}; |
|
||||
|
|
||||
it("renders the dialog when open is true", () => { |
|
||||
renderWithProviders( |
|
||||
<UnsafeRolesDialog open onOpenChange={vi.fn()} />, |
|
||||
); |
|
||||
|
|
||||
const dialog = screen.getByRole("dialog"); |
|
||||
expect(dialog).toBeInTheDocument(); |
|
||||
|
|
||||
expect(screen.getByText(/I have read the/i)).toBeInTheDocument(); |
|
||||
expect(screen.getByText(/understand the implications/i)) |
|
||||
.toBeInTheDocument(); |
|
||||
|
|
||||
const links = screen.getAllByRole("link"); |
|
||||
expect(links).toHaveLength(2); |
|
||||
expect(links[0]).toHaveTextContent("Device Role Documentation"); |
|
||||
expect(links[1]).toHaveTextContent("Choosing The Right Device Role"); |
|
||||
}); |
|
||||
|
|
||||
it("displays the correct links", () => { |
|
||||
renderWithProviders( |
|
||||
<UnsafeRolesDialog open onOpenChange={vi.fn()} />, |
|
||||
); |
|
||||
|
|
||||
const docLink = screen.getByRole("link", { |
|
||||
name: /Device Role Documentation/i, |
|
||||
}); |
|
||||
const blogLink = screen.getByRole("link", { |
|
||||
name: /Choosing The Right Device Role/i, |
|
||||
}); |
|
||||
|
|
||||
expect(docLink).toHaveAttribute( |
|
||||
"to", |
|
||||
"https://meshtastic.org/docs/configuration/radio/device/", |
|
||||
); |
|
||||
expect(blogLink).toHaveAttribute( |
|
||||
"to", |
|
||||
"https://meshtastic.org/blog/choosing-the-right-device-role/", |
|
||||
); |
|
||||
}); |
|
||||
|
|
||||
it("does not allow confirmation until checkbox is checked", () => { |
|
||||
renderWithProviders( |
|
||||
<UnsafeRolesDialog open onOpenChange={vi.fn()} />, |
|
||||
); |
|
||||
|
|
||||
const confirmButton = screen.getByRole("button", { name: /confirm/i }); |
|
||||
|
|
||||
expect(confirmButton).toBeDisabled(); |
|
||||
|
|
||||
const checkbox = screen.getByRole("checkbox"); |
|
||||
fireEvent.click(checkbox); |
|
||||
|
|
||||
expect(confirmButton).toBeEnabled(); |
|
||||
}); |
|
||||
|
|
||||
it("emits the correct event when closing via close button", () => { |
|
||||
const eventSpy = vi.spyOn(eventBus, "emit"); |
|
||||
renderWithProviders( |
|
||||
<UnsafeRolesDialog open onOpenChange={vi.fn()} />, |
|
||||
); |
|
||||
|
|
||||
const dismissButton = screen.getByRole("button", { name: /close/i }); |
|
||||
fireEvent.click(dismissButton); |
|
||||
|
|
||||
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", { |
|
||||
action: "dismiss", |
|
||||
}); |
|
||||
}); |
|
||||
|
|
||||
it("emits the correct event when dismissing", () => { |
|
||||
const eventSpy = vi.spyOn(eventBus, "emit"); |
|
||||
renderWithProviders( |
|
||||
<UnsafeRolesDialog open onOpenChange={vi.fn()} />, |
|
||||
); |
|
||||
|
|
||||
const dismissButton = screen.getByRole("button", { name: /dismiss/i }); |
|
||||
fireEvent.click(dismissButton); |
|
||||
|
|
||||
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", { |
|
||||
action: "dismiss", |
|
||||
}); |
|
||||
}); |
|
||||
|
|
||||
it("emits the correct event when confirming", () => { |
|
||||
const eventSpy = vi.spyOn(eventBus, "emit"); |
|
||||
renderWithProviders( |
|
||||
<UnsafeRolesDialog open onOpenChange={vi.fn()} />, |
|
||||
); |
|
||||
|
|
||||
const checkbox = screen.getByRole("checkbox"); |
|
||||
const confirmButton = screen.getByRole("button", { name: /confirm/i }); |
|
||||
|
|
||||
fireEvent.click(checkbox); |
|
||||
fireEvent.click(confirmButton); |
|
||||
|
|
||||
expect(eventSpy).toHaveBeenCalledWith("dialog:unsafeRoles", { |
|
||||
action: "confirm", |
|
||||
}); |
|
||||
}); |
|
||||
}); |
|
||||
@ -1,314 +0,0 @@ |
|||||
import { describe, expect, it, vi } from "vitest"; |
|
||||
import { fireEvent, render, screen, waitFor } from "@core/utils/test.tsx"; |
|
||||
import { DynamicForm } from "./DynamicForm.tsx"; |
|
||||
import { z } from "zod/v4"; |
|
||||
import { useAppStore } from "@core/stores/appStore.ts"; |
|
||||
import userEvent from "@testing-library/user-event"; |
|
||||
|
|
||||
vi.mock("react-i18next", () => ({ |
|
||||
useTranslation: () => ({ |
|
||||
t: (key: string | string[]) => (Array.isArray(key) ? key[0] : key), |
|
||||
}), |
|
||||
})); |
|
||||
|
|
||||
const addErrorMock = vi.fn(); |
|
||||
const removeErrorMock = vi.fn(); |
|
||||
|
|
||||
vi.mock("@core/stores/appStore.ts", () => ({ |
|
||||
useAppStore: () => ({ |
|
||||
addError: addErrorMock, |
|
||||
removeError: removeErrorMock, |
|
||||
}), |
|
||||
})); |
|
||||
|
|
||||
describe.skip("DynamicForm", () => { |
|
||||
const schema = z.object({ |
|
||||
name: z.string().min(3, { message: "Too short" }), |
|
||||
}); |
|
||||
|
|
||||
const fieldGroups = [ |
|
||||
{ |
|
||||
label: "Test Group", |
|
||||
description: "Testing validation", |
|
||||
fields: [ |
|
||||
{ |
|
||||
type: "text", |
|
||||
id: "name", |
|
||||
name: "name", |
|
||||
label: "Name", |
|
||||
description: "Enter your name", |
|
||||
properties: {}, |
|
||||
}, |
|
||||
], |
|
||||
}, |
|
||||
]; |
|
||||
|
|
||||
it("shows validation error when input is too short", async () => { |
|
||||
render( |
|
||||
<DynamicForm<z.infer<typeof schema>> |
|
||||
onSubmit={vi.fn()} |
|
||||
validationSchema={schema} |
|
||||
defaultValues={{ name: "" }} |
|
||||
fieldGroups={fieldGroups} |
|
||||
/>, |
|
||||
); |
|
||||
const input = screen.getByLabelText("Name") as HTMLInputElement; |
|
||||
|
|
||||
fireEvent.input(input, { target: { value: "ab" } }); |
|
||||
|
|
||||
const error = await screen.findByText( |
|
||||
"formValidation.tooSmall.string", |
|
||||
); |
|
||||
expect(error).toBeVisible(); |
|
||||
}); |
|
||||
|
|
||||
it("clears validation error when input becomes valid", async () => { |
|
||||
render( |
|
||||
<DynamicForm<z.infer<typeof schema>> |
|
||||
onSubmit={vi.fn()} |
|
||||
validationSchema={schema} |
|
||||
defaultValues={{ name: "" }} |
|
||||
fieldGroups={fieldGroups} |
|
||||
/>, |
|
||||
); |
|
||||
const input = screen.getByLabelText("Name") as HTMLInputElement; |
|
||||
|
|
||||
fireEvent.input(input, { target: { value: "ab" } }); |
|
||||
expect( |
|
||||
await screen.findByText("formValidation.tooSmall.string"), |
|
||||
).toBeVisible(); |
|
||||
|
|
||||
fireEvent.input(input, { target: { value: "abcd" } }); |
|
||||
await waitFor(() => |
|
||||
expect( |
|
||||
screen.queryByText("formValidation.tooSmall.string"), |
|
||||
).toBeNull() |
|
||||
); |
|
||||
}); |
|
||||
|
|
||||
it("calls onSubmit when form is valid onChange", async () => { |
|
||||
const onSubmit = vi.fn(); |
|
||||
render( |
|
||||
<DynamicForm<z.infer<typeof schema>> |
|
||||
onSubmit={onSubmit} |
|
||||
validationSchema={schema} |
|
||||
defaultValues={{ name: "" }} |
|
||||
fieldGroups={fieldGroups} |
|
||||
/>, |
|
||||
); |
|
||||
|
|
||||
const input = screen.getByLabelText("Name") as HTMLInputElement; |
|
||||
|
|
||||
fireEvent.input(input, { target: { value: "ab" } }); |
|
||||
expect( |
|
||||
await screen.findByText("formValidation.tooSmall.string"), |
|
||||
).toBeVisible(); |
|
||||
|
|
||||
fireEvent.input(input, { target: { value: "abcd" } }); |
|
||||
|
|
||||
await waitFor(() => { |
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1); |
|
||||
}); |
|
||||
|
|
||||
expect(onSubmit).toHaveBeenCalledWith( |
|
||||
{ name: "abcd" }, |
|
||||
expect.any(Object), |
|
||||
); |
|
||||
}); |
|
||||
|
|
||||
it("renders a button and only calls onSubmit on click with submitType='onSubmit'", async () => { |
|
||||
// Use the userEvent setup
|
|
||||
const user = userEvent.setup(); |
|
||||
const onSubmit = vi.fn(); |
|
||||
|
|
||||
render( |
|
||||
<DynamicForm<z.infer<typeof schema>> |
|
||||
onSubmit={onSubmit} |
|
||||
submitType="onSubmit" |
|
||||
hasSubmitButton |
|
||||
validationSchema={schema} |
|
||||
defaultValues={{ name: "" }} |
|
||||
fieldGroups={fieldGroups} |
|
||||
/>, |
|
||||
); |
|
||||
|
|
||||
const nameInput = screen.getByLabelText("Name"); |
|
||||
const submitButton = screen.getByRole("button", { name: /submit/i }); |
|
||||
|
|
||||
expect(submitButton).toBeInTheDocument(); |
|
||||
await user.type(nameInput, "ab"); |
|
||||
|
|
||||
expect(await screen.findByText("formValidation.tooSmall.string")) |
|
||||
.toBeInTheDocument(); |
|
||||
await user.click(submitButton); |
|
||||
expect(onSubmit).not.toHaveBeenCalled(); |
|
||||
|
|
||||
await user.clear(nameInput); |
|
||||
await user.type(nameInput, "abcd"); |
|
||||
|
|
||||
await waitFor(() => { |
|
||||
expect(screen.queryByText("formValidation.tooSmall.string")).not |
|
||||
.toBeInTheDocument(); |
|
||||
}); |
|
||||
await user.click(submitButton); |
|
||||
await waitFor(() => { |
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1); |
|
||||
}); |
|
||||
|
|
||||
expect(onSubmit).toHaveBeenCalledWith({ name: "abcd" }, expect.any(Object)); |
|
||||
}); |
|
||||
|
|
||||
it("renders defaultValues correctly", () => { |
|
||||
render( |
|
||||
<DynamicForm<{ name: string }> |
|
||||
onSubmit={vi.fn()} |
|
||||
// no validationSchema
|
|
||||
defaultValues={{ name: "Alice" }} |
|
||||
fieldGroups={[ |
|
||||
{ |
|
||||
label: "Group", |
|
||||
description: "", |
|
||||
fields: [ |
|
||||
{ |
|
||||
type: "text", |
|
||||
name: "name", |
|
||||
label: "Name", |
|
||||
description: "", |
|
||||
properties: {}, |
|
||||
}, |
|
||||
], |
|
||||
}, |
|
||||
]} |
|
||||
/>, |
|
||||
); |
|
||||
const input = screen.getByLabelText("Name") as HTMLInputElement; |
|
||||
expect(input.value).toBe("Alice"); |
|
||||
}); |
|
||||
|
|
||||
it("toggles disabled state based on disabledBy rules", async () => { |
|
||||
const schema = z.object({ |
|
||||
enable: z.boolean(), |
|
||||
follow: z.string(), |
|
||||
}); |
|
||||
render( |
|
||||
<DynamicForm<z.infer<typeof schema>> |
|
||||
onSubmit={vi.fn()} |
|
||||
validationSchema={schema} |
|
||||
defaultValues={{ enable: false, follow: "" }} |
|
||||
fieldGroups={[ |
|
||||
{ |
|
||||
label: "Group", |
|
||||
description: "", |
|
||||
fields: [ |
|
||||
{ |
|
||||
type: "toggle", |
|
||||
name: "enable", |
|
||||
label: "enable", |
|
||||
description: "", |
|
||||
}, |
|
||||
{ |
|
||||
type: "text", |
|
||||
name: "follow", |
|
||||
label: "follow", |
|
||||
description: "", |
|
||||
disabledBy: [{ fieldName: "enable" }], |
|
||||
properties: {}, |
|
||||
}, |
|
||||
], |
|
||||
}, |
|
||||
]} |
|
||||
/>, |
|
||||
); |
|
||||
const enable = screen.getByRole("switch", { |
|
||||
name: "enable", |
|
||||
}) as HTMLInputElement; |
|
||||
|
|
||||
const follow = screen.getByLabelText("follow") as HTMLInputElement; |
|
||||
await waitFor(() => { |
|
||||
expect(enable.getAttribute("aria-checked")).toBe("false"); |
|
||||
expect(follow).toBeDisabled(); |
|
||||
}); |
|
||||
|
|
||||
fireEvent.click(enable); |
|
||||
await waitFor(() => { |
|
||||
expect(enable.getAttribute("aria-checked")).toBe("true"); |
|
||||
expect(follow).not.toBeDisabled(); |
|
||||
}); |
|
||||
}); |
|
||||
|
|
||||
it("always calls onSubmit onChange when no validationSchema is provided", async () => { |
|
||||
const onSubmit = vi.fn(); |
|
||||
render( |
|
||||
<DynamicForm<{ foo: string }> |
|
||||
onSubmit={onSubmit} |
|
||||
// no validationSchema
|
|
||||
defaultValues={{ foo: "" }} |
|
||||
fieldGroups={[ |
|
||||
{ |
|
||||
label: "G", |
|
||||
description: "", |
|
||||
fields: [ |
|
||||
{ |
|
||||
type: "text", |
|
||||
name: "foo", |
|
||||
label: "Foo", |
|
||||
description: "", |
|
||||
properties: {}, |
|
||||
}, |
|
||||
], |
|
||||
}, |
|
||||
]} |
|
||||
/>, |
|
||||
); |
|
||||
const input = screen.getByLabelText("Foo") as HTMLInputElement; |
|
||||
fireEvent.input(input, { target: { value: "bar" } }); |
|
||||
|
|
||||
await waitFor(() => { |
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1); |
|
||||
expect(onSubmit).toHaveBeenCalledWith({ foo: "bar" }, expect.any(Object)); |
|
||||
}); |
|
||||
}); |
|
||||
|
|
||||
it("syncs errors to appStore when formId is set", async () => { |
|
||||
const { addError, removeError } = useAppStore(); |
|
||||
const schema = z.object({ foo: z.string().min(2) }); |
|
||||
const groups = [ |
|
||||
{ |
|
||||
label: "G", |
|
||||
description: "", |
|
||||
fields: [ |
|
||||
{ |
|
||||
type: "text", |
|
||||
name: "foo", |
|
||||
label: "Foo", |
|
||||
description: "", |
|
||||
properties: {}, |
|
||||
}, |
|
||||
], |
|
||||
}, |
|
||||
]; |
|
||||
|
|
||||
render( |
|
||||
<DynamicForm<z.infer<typeof schema>> |
|
||||
onSubmit={vi.fn()} |
|
||||
formId="myForm" |
|
||||
validationSchema={schema} |
|
||||
defaultValues={{ foo: "" }} |
|
||||
fieldGroups={groups} |
|
||||
/>, |
|
||||
); |
|
||||
const input = screen.getByLabelText("Foo") as HTMLInputElement; |
|
||||
|
|
||||
fireEvent.input(input, { target: { value: "a" } }); |
|
||||
await screen.findByText(/tooSmall/i); |
|
||||
|
|
||||
expect(addError).toHaveBeenCalledWith("foo", ""); |
|
||||
expect(addError).toHaveBeenCalledWith("myForm", ""); |
|
||||
|
|
||||
fireEvent.input(input, { target: { value: "abc" } }); |
|
||||
await waitFor(() => { |
|
||||
expect(removeError).toHaveBeenCalledWith("foo"); |
|
||||
expect(removeError).toHaveBeenCalledWith("myForm"); |
|
||||
}); |
|
||||
}); |
|
||||
}); |
|
||||
@ -1,131 +0,0 @@ |
|||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; |
|
||||
import { Device } from "@components/PageComponents/Config/Device/index.tsx"; |
|
||||
import { useDevice } from "@core/stores/deviceStore.ts"; |
|
||||
import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts"; |
|
||||
import { Protobuf } from "@meshtastic/core"; |
|
||||
|
|
||||
vi.mock("@core/stores/deviceStore.ts", () => ({ |
|
||||
useDevice: vi.fn(), |
|
||||
})); |
|
||||
|
|
||||
vi.mock("@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts", () => ({ |
|
||||
useUnsafeRolesDialog: vi.fn(), |
|
||||
})); |
|
||||
|
|
||||
// Mock the DynamicForm component since we're testing the Device component,
|
|
||||
// not the DynamicForm implementation
|
|
||||
vi.mock("@components/Form/DynamicForm", () => ({ |
|
||||
DynamicForm: vi.fn(({ onSubmit }) => { |
|
||||
// Render a simplified version of the form for testing
|
|
||||
return ( |
|
||||
<div data-testid="dynamic-form"> |
|
||||
<select |
|
||||
data-testid="role-select" |
|
||||
onChange={(e) => { |
|
||||
// Simulate the validation and submission process
|
|
||||
const mockData = { role: e.target.value }; |
|
||||
onSubmit(mockData); |
|
||||
}} |
|
||||
> |
|
||||
{Object.entries(Protobuf.Config.Config_DeviceConfig_Role).map(( |
|
||||
[key, value], |
|
||||
) => ( |
|
||||
<option key={key} value={value}> |
|
||||
{key} |
|
||||
</option> |
|
||||
))} |
|
||||
</select> |
|
||||
<button |
|
||||
type="submit" |
|
||||
data-testid="submit-button" |
|
||||
onClick={() => onSubmit({ role: "CLIENT" })} |
|
||||
> |
|
||||
Submit |
|
||||
</button> |
|
||||
</div> |
|
||||
); |
|
||||
}), |
|
||||
})); |
|
||||
|
|
||||
describe("Device component", () => { |
|
||||
const setWorkingConfigMock = vi.fn(); |
|
||||
const validateRoleSelectionMock = vi.fn(); |
|
||||
const mockDeviceConfig = { |
|
||||
role: "CLIENT", |
|
||||
buttonGpio: 0, |
|
||||
buzzerGpio: 0, |
|
||||
rebroadcastMode: "ALL", |
|
||||
nodeInfoBroadcastSecs: 300, |
|
||||
doubleTapAsButtonPress: false, |
|
||||
disableTripleClick: false, |
|
||||
ledHeartbeatDisabled: false, |
|
||||
}; |
|
||||
|
|
||||
beforeEach(() => { |
|
||||
vi.resetAllMocks(); |
|
||||
|
|
||||
// Mock the useDevice hook
|
|
||||
useDevice.mockReturnValue({ |
|
||||
config: { |
|
||||
device: mockDeviceConfig, |
|
||||
}, |
|
||||
setWorkingConfig: setWorkingConfigMock, |
|
||||
}); |
|
||||
|
|
||||
// Mock the useUnsafeRolesDialog hook
|
|
||||
validateRoleSelectionMock.mockResolvedValue(true); |
|
||||
useUnsafeRolesDialog.mockReturnValue({ |
|
||||
validateRoleSelection: validateRoleSelectionMock, |
|
||||
}); |
|
||||
}); |
|
||||
|
|
||||
afterEach(() => { |
|
||||
vi.clearAllMocks(); |
|
||||
}); |
|
||||
|
|
||||
it("should render the Device form", () => { |
|
||||
render(<Device />); |
|
||||
expect(screen.getByTestId("dynamic-form")).toBeInTheDocument(); |
|
||||
}); |
|
||||
|
|
||||
it("should use the validateRoleSelection from the unsafe roles hook", () => { |
|
||||
render(<Device />); |
|
||||
expect(useUnsafeRolesDialog).toHaveBeenCalled(); |
|
||||
}); |
|
||||
|
|
||||
it("should call setWorkingConfig when form is submitted", async () => { |
|
||||
render(<Device />); |
|
||||
|
|
||||
fireEvent.click(screen.getByTestId("submit-button")); |
|
||||
|
|
||||
await waitFor(() => { |
|
||||
expect(setWorkingConfigMock).toHaveBeenCalledWith( |
|
||||
expect.objectContaining({ |
|
||||
payloadVariant: { |
|
||||
case: "device", |
|
||||
value: expect.objectContaining({ role: "CLIENT" }), |
|
||||
}, |
|
||||
}), |
|
||||
); |
|
||||
}); |
|
||||
}); |
|
||||
|
|
||||
it("should create config with proper structure", async () => { |
|
||||
render(<Device />); |
|
||||
|
|
||||
// Simulate form submission
|
|
||||
fireEvent.click(screen.getByTestId("submit-button")); |
|
||||
|
|
||||
await waitFor(() => { |
|
||||
expect(setWorkingConfigMock).toHaveBeenCalledWith( |
|
||||
expect.objectContaining({ |
|
||||
payloadVariant: { |
|
||||
case: "device", |
|
||||
value: expect.any(Object), |
|
||||
}, |
|
||||
}), |
|
||||
); |
|
||||
}); |
|
||||
}); |
|
||||
}); |
|
||||
@ -1,173 +0,0 @@ |
|||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; |
|
||||
import { Network } from "@components/PageComponents/Config/Network/index.tsx"; |
|
||||
import { useDevice } from "@core/stores/deviceStore.ts"; |
|
||||
import { Protobuf } from "@meshtastic/core"; |
|
||||
|
|
||||
vi.mock("@core/stores/deviceStore", () => ({ |
|
||||
useDevice: vi.fn(), |
|
||||
})); |
|
||||
|
|
||||
vi.mock("@components/Form/DynamicForm", async () => { |
|
||||
const React = await import("react"); |
|
||||
const { useState } = React; |
|
||||
|
|
||||
return { |
|
||||
DynamicForm: ({ onSubmit, defaultValues }) => { |
|
||||
const [wifiEnabled, setWifiEnabled] = useState( |
|
||||
defaultValues.wifiEnabled ?? false, |
|
||||
); |
|
||||
const [ssid, setSsid] = useState(defaultValues.wifiSsid ?? ""); |
|
||||
const [psk, setPsk] = useState(defaultValues.wifiPsk ?? ""); |
|
||||
|
|
||||
return ( |
|
||||
<form |
|
||||
onSubmit={(e) => { |
|
||||
e.preventDefault(); |
|
||||
onSubmit({ |
|
||||
...defaultValues, |
|
||||
wifiEnabled, |
|
||||
wifiSsid: ssid, |
|
||||
wifiPsk: psk, |
|
||||
}); |
|
||||
}} |
|
||||
data-testid="dynamic-form" |
|
||||
> |
|
||||
<input |
|
||||
type="checkbox" |
|
||||
aria-label="WiFi Enabled" |
|
||||
checked={wifiEnabled} |
|
||||
onChange={(e) => setWifiEnabled(e.target.checked)} |
|
||||
/> |
|
||||
<input |
|
||||
aria-label="SSID" |
|
||||
value={ssid} |
|
||||
onChange={(e) => setSsid(e.target.value)} |
|
||||
disabled={!wifiEnabled} |
|
||||
/> |
|
||||
<input |
|
||||
aria-label="PSK" |
|
||||
value={psk} |
|
||||
onChange={(e) => setPsk(e.target.value)} |
|
||||
disabled={!wifiEnabled} |
|
||||
/> |
|
||||
<button type="submit" data-testid="submit-button"> |
|
||||
Submit |
|
||||
</button> |
|
||||
</form> |
|
||||
); |
|
||||
}, |
|
||||
}; |
|
||||
}); |
|
||||
|
|
||||
describe("Network component", () => { |
|
||||
const setWorkingConfigMock = vi.fn(); |
|
||||
const mockNetworkConfig = { |
|
||||
wifiEnabled: false, |
|
||||
wifiSsid: "", |
|
||||
wifiPsk: "", |
|
||||
ntpServer: "", |
|
||||
ethEnabled: false, |
|
||||
addressMode: Protobuf.Config.Config_NetworkConfig_AddressMode.DHCP, |
|
||||
ipv4Config: { |
|
||||
ip: 0, |
|
||||
gateway: 0, |
|
||||
subnet: 0, |
|
||||
dns: 0, |
|
||||
}, |
|
||||
enabledProtocols: |
|
||||
Protobuf.Config.Config_NetworkConfig_ProtocolFlags.NO_BROADCAST, |
|
||||
rsyslogServer: "", |
|
||||
}; |
|
||||
|
|
||||
beforeEach(() => { |
|
||||
vi.resetAllMocks(); |
|
||||
|
|
||||
useDevice.mockReturnValue({ |
|
||||
config: { |
|
||||
network: mockNetworkConfig, |
|
||||
}, |
|
||||
setWorkingConfig: setWorkingConfigMock, |
|
||||
}); |
|
||||
}); |
|
||||
|
|
||||
afterEach(() => { |
|
||||
vi.clearAllMocks(); |
|
||||
}); |
|
||||
|
|
||||
it("should render the Network form", () => { |
|
||||
render(<Network />); |
|
||||
expect(screen.getByTestId("dynamic-form")).toBeInTheDocument(); |
|
||||
}); |
|
||||
|
|
||||
it("should disable SSID and PSK fields when wifi is off", () => { |
|
||||
render(<Network />); |
|
||||
expect(screen.getByLabelText("SSID")).toBeDisabled(); |
|
||||
expect(screen.getByLabelText("PSK")).toBeDisabled(); |
|
||||
}); |
|
||||
|
|
||||
it("should enable SSID and PSK when wifi is toggled on", async () => { |
|
||||
render(<Network />); |
|
||||
const toggle = screen.getByLabelText("WiFi Enabled"); |
|
||||
fireEvent.click(toggle); // turns wifiEnabled = true
|
|
||||
|
|
||||
await waitFor(() => { |
|
||||
expect(screen.getByLabelText("SSID")).not.toBeDisabled(); |
|
||||
expect(screen.getByLabelText("PSK")).not.toBeDisabled(); |
|
||||
}); |
|
||||
}); |
|
||||
|
|
||||
it("should call setWorkingConfig with the right structure on submit", async () => { |
|
||||
render(<Network />); |
|
||||
|
|
||||
fireEvent.click(screen.getByTestId("submit-button")); |
|
||||
|
|
||||
await waitFor(() => { |
|
||||
expect(setWorkingConfigMock).toHaveBeenCalledWith( |
|
||||
expect.objectContaining({ |
|
||||
payloadVariant: { |
|
||||
case: "network", |
|
||||
value: expect.objectContaining({ |
|
||||
wifiEnabled: false, |
|
||||
wifiSsid: "", |
|
||||
wifiPsk: "", |
|
||||
ntpServer: "", |
|
||||
ethEnabled: false, |
|
||||
rsyslogServer: "", |
|
||||
}), |
|
||||
}, |
|
||||
}), |
|
||||
); |
|
||||
}); |
|
||||
}); |
|
||||
|
|
||||
it("should submit valid data after enabling wifi and entering SSID and PSK", async () => { |
|
||||
render(<Network />); |
|
||||
fireEvent.click(screen.getByLabelText("WiFi Enabled")); |
|
||||
|
|
||||
fireEvent.change(screen.getByLabelText("SSID"), { |
|
||||
target: { value: "MySSID" }, |
|
||||
}); |
|
||||
|
|
||||
fireEvent.change(screen.getByLabelText("PSK"), { |
|
||||
target: { value: "MySecretPSK" }, |
|
||||
}); |
|
||||
|
|
||||
fireEvent.click(screen.getByTestId("submit-button")); |
|
||||
|
|
||||
await waitFor(() => { |
|
||||
expect(setWorkingConfigMock).toHaveBeenCalledWith( |
|
||||
expect.objectContaining({ |
|
||||
payloadVariant: { |
|
||||
case: "network", |
|
||||
value: expect.objectContaining({ |
|
||||
wifiEnabled: true, |
|
||||
wifiSsid: "MySSID", |
|
||||
wifiPsk: "MySecretPSK", |
|
||||
}), |
|
||||
}, |
|
||||
}), |
|
||||
); |
|
||||
}); |
|
||||
}); |
|
||||
}); |
|
||||
@ -1,153 +1,185 @@ |
|||||
import { Protobuf } from "@meshtastic/core"; |
import { Protobuf } from "@meshtastic/core"; |
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; |
import { numberToHexUnpadded } from "@noble/curves/abstract/utils"; |
||||
import { useMemo } from "react"; |
import { useCallback, useMemo } from "react"; |
||||
|
|
||||
export type FilterState = { |
export type FilterState = { |
||||
nodeName: string; |
nodeName: string; |
||||
hopsAway: [number, number]; |
hopsAway: [number, number]; |
||||
lastHeard: [number, number]; |
lastHeard: [number, number]; |
||||
isFavorite: boolean | undefined; // undefined -> don't filter
|
isFavorite: boolean | undefined; |
||||
viaMqtt: boolean | undefined; // undefined -> don't filter
|
viaMqtt: boolean | undefined; |
||||
snr: [number, number]; |
snr: [number, number]; |
||||
channelUtilization: [number, number]; |
channelUtilization: [number, number]; |
||||
airUtilTx: [number, number]; |
airUtilTx: [number, number]; |
||||
batteryLevel: [number, number]; |
batteryLevel: [number, number]; |
||||
voltage: [number, number]; |
voltage: [number, number]; |
||||
role: (Protobuf.Config.Config_DeviceConfig_Role)[]; |
role: Protobuf.Config.Config_DeviceConfig_Role[]; |
||||
hwModel: (Protobuf.Mesh.HardwareModel)[]; |
hwModel: Protobuf.Mesh.HardwareModel[]; |
||||
}; |
}; |
||||
|
|
||||
export function useFilterNode() { |
const shallowEqualArray = <T>(a: T[], b: T[]): boolean => { |
||||
const defaultFilterValues = useMemo<FilterState>(() => ({ |
if (a.length !== b.length) { |
||||
nodeName: "", |
return false; |
||||
hopsAway: [0, 7], |
} |
||||
lastHeard: [0, 864000], // 0-10 days
|
for (let i = 0; i < a.length; i++) { |
||||
isFavorite: undefined, |
if (a[i] !== b[i]) { |
||||
viaMqtt: undefined, |
|
||||
snr: [-20, 10], |
|
||||
channelUtilization: [0, 100], |
|
||||
airUtilTx: [0, 100], |
|
||||
batteryLevel: [0, 101], |
|
||||
voltage: [0, 5], |
|
||||
role: Object.values(Protobuf.Config.Config_DeviceConfig_Role).filter( |
|
||||
(v): v is Protobuf.Config.Config_DeviceConfig_Role => |
|
||||
typeof v === "number", |
|
||||
), |
|
||||
hwModel: Object.values(Protobuf.Mesh.HardwareModel).filter( |
|
||||
(v): v is Protobuf.Mesh.HardwareModel => typeof v === "number", |
|
||||
), |
|
||||
}), []); |
|
||||
|
|
||||
function nodeFilter( |
|
||||
node: Protobuf.Mesh.NodeInfo, |
|
||||
filterOverrides?: Partial<FilterState>, |
|
||||
): boolean { |
|
||||
const filterState: FilterState = { |
|
||||
...defaultFilterValues, |
|
||||
...filterOverrides, |
|
||||
}; |
|
||||
|
|
||||
if (!node.user) return false; |
|
||||
|
|
||||
const nodeName = filterState.nodeName.toLowerCase(); |
|
||||
if ( |
|
||||
!( |
|
||||
node.user?.shortName.toLowerCase().includes(nodeName) || |
|
||||
node.user?.longName.toLowerCase().includes(nodeName) || |
|
||||
node?.num.toString().includes(nodeName) || |
|
||||
numberToHexUnpadded(node?.num).includes( |
|
||||
nodeName.replace(/!/g, ""), |
|
||||
) |
|
||||
) |
|
||||
) return false; |
|
||||
|
|
||||
const hops = node?.hopsAway ?? 7; |
|
||||
if (hops < filterState.hopsAway[0] || hops > filterState.hopsAway[1]) { |
|
||||
return false; |
return false; |
||||
} |
} |
||||
|
|
||||
const secondsAgo = Date.now() / 1000 - (node?.lastHeard ?? 0); |
|
||||
if ( |
|
||||
secondsAgo < filterState.lastHeard[0] || |
|
||||
( |
|
||||
secondsAgo > filterState.lastHeard[1] && |
|
||||
filterState.lastHeard[1] !== defaultFilterValues.lastHeard[1] |
|
||||
) |
|
||||
) return false; |
|
||||
|
|
||||
if ( |
|
||||
typeof filterState.isFavorite !== "undefined" && |
|
||||
node.isFavorite !== filterState.isFavorite |
|
||||
) return false; |
|
||||
|
|
||||
if ( |
|
||||
typeof filterState.viaMqtt !== "undefined" && |
|
||||
node.viaMqtt !== filterState.viaMqtt |
|
||||
) return false; |
|
||||
|
|
||||
const snr = node?.snr ?? -20; |
|
||||
if ( |
|
||||
snr < filterState.snr[0] || |
|
||||
snr > filterState.snr[1] |
|
||||
) return false; |
|
||||
|
|
||||
const channelUtilization = node?.deviceMetrics?.channelUtilization ?? 0; |
|
||||
if ( |
|
||||
channelUtilization < filterState.channelUtilization[0] || |
|
||||
channelUtilization > filterState.channelUtilization[1] |
|
||||
) return false; |
|
||||
|
|
||||
const airUtilTx = node?.deviceMetrics?.airUtilTx ?? 0; |
|
||||
if ( |
|
||||
airUtilTx < filterState.airUtilTx[0] || |
|
||||
airUtilTx > filterState.airUtilTx[1] |
|
||||
) return false; |
|
||||
|
|
||||
const batt = node?.deviceMetrics?.batteryLevel ?? 101; |
|
||||
if ( |
|
||||
batt < filterState.batteryLevel[0] || |
|
||||
batt > filterState.batteryLevel[1] |
|
||||
) return false; |
|
||||
|
|
||||
const voltage = node?.deviceMetrics?.voltage ?? 0; |
|
||||
if ( |
|
||||
voltage < filterState.voltage[0] || |
|
||||
voltage > filterState.voltage[1] |
|
||||
) return false; |
|
||||
|
|
||||
const role: Protobuf.Config.Config_DeviceConfig_Role = node.user?.role ?? |
|
||||
Protobuf.Config.Config_DeviceConfig_Role.CLIENT; |
|
||||
if (!filterState.role.includes(role)) return false; |
|
||||
|
|
||||
const hwModel: Protobuf.Mesh.HardwareModel = node.user?.hwModel ?? |
|
||||
Protobuf.Mesh.HardwareModel.UNSET; |
|
||||
if (!filterState.hwModel.includes(hwModel)) return false; |
|
||||
|
|
||||
// All conditions are true
|
|
||||
return true; |
|
||||
} |
} |
||||
|
return true; |
||||
|
}; |
||||
|
|
||||
// deno-lint-ignore no-explicit-any
|
export function useFilterNode() { |
||||
function shallowEqualArray(a: any[], b: any[]) { |
const defaultFilterValues = useMemo<FilterState>( |
||||
return a.length === b.length && a.every((v, i) => v === b[i]); |
() => ({ |
||||
} |
nodeName: "", |
||||
|
hopsAway: [0, 7], |
||||
|
lastHeard: [0, 864000], // 0-10 days
|
||||
|
isFavorite: undefined, |
||||
|
viaMqtt: undefined, |
||||
|
snr: [-20, 10], |
||||
|
channelUtilization: [0, 100], |
||||
|
airUtilTx: [0, 100], |
||||
|
batteryLevel: [0, 101], |
||||
|
voltage: [0, 5], |
||||
|
role: Object.values(Protobuf.Config.Config_DeviceConfig_Role).filter( |
||||
|
(v): v is Protobuf.Config.Config_DeviceConfig_Role => |
||||
|
typeof v === "number", |
||||
|
), |
||||
|
hwModel: Object.values(Protobuf.Mesh.HardwareModel).filter( |
||||
|
(v): v is Protobuf.Mesh.HardwareModel => typeof v === "number", |
||||
|
), |
||||
|
}), |
||||
|
[], |
||||
|
); |
||||
|
|
||||
|
const nodeFilter = useCallback( |
||||
|
( |
||||
|
node: Protobuf.Mesh.NodeInfo, |
||||
|
filterOverrides?: Partial<FilterState>, |
||||
|
): boolean => { |
||||
|
const filterState: FilterState = { |
||||
|
...defaultFilterValues, |
||||
|
...filterOverrides, |
||||
|
}; |
||||
|
|
||||
|
if (!node.user) return false; |
||||
|
|
||||
|
const nodeName = filterState.nodeName.toLowerCase(); |
||||
|
if ( |
||||
|
nodeName && |
||||
|
!( |
||||
|
node.user?.shortName.toLowerCase().includes(nodeName) || |
||||
|
node.user?.longName.toLowerCase().includes(nodeName) || |
||||
|
node.num.toString().includes(nodeName) || |
||||
|
numberToHexUnpadded(node.num).includes(nodeName.replace(/!/g, "")) |
||||
|
) |
||||
|
) { |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
const hops = node.hopsAway ?? 7; |
||||
|
if (hops < filterState.hopsAway[0] || hops > filterState.hopsAway[1]) { |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
const secondsAgo = Date.now() / 1000 - (node.lastHeard ?? 0); |
||||
|
if ( |
||||
|
secondsAgo < filterState.lastHeard[0] || |
||||
|
(secondsAgo > filterState.lastHeard[1] && |
||||
|
filterState.lastHeard[1] !== defaultFilterValues.lastHeard[1]) |
||||
|
) { |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
if ( |
||||
|
typeof filterState.isFavorite !== "undefined" && |
||||
|
node.isFavorite !== filterState.isFavorite |
||||
|
) { |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
if ( |
||||
|
typeof filterState.viaMqtt !== "undefined" && |
||||
|
node.viaMqtt !== filterState.viaMqtt |
||||
|
) { |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
const snr = node.snr ?? -20; |
||||
|
if (snr < filterState.snr[0] || snr > filterState.snr[1]) return false; |
||||
|
|
||||
|
const channelUtilization = node.deviceMetrics?.channelUtilization ?? 0; |
||||
|
if ( |
||||
|
channelUtilization < filterState.channelUtilization[0] || |
||||
|
channelUtilization > filterState.channelUtilization[1] |
||||
|
) { |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
const airUtilTx = node.deviceMetrics?.airUtilTx ?? 0; |
||||
|
if ( |
||||
|
airUtilTx < filterState.airUtilTx[0] || |
||||
|
airUtilTx > filterState.airUtilTx[1] |
||||
|
) { |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
const batt = node.deviceMetrics?.batteryLevel ?? 101; |
||||
|
if ( |
||||
|
batt < filterState.batteryLevel[0] || |
||||
|
batt > filterState.batteryLevel[1] |
||||
|
) { |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
const voltage = node.deviceMetrics?.voltage ?? 0; |
||||
|
if ( |
||||
|
voltage < filterState.voltage[0] || |
||||
|
voltage > filterState.voltage[1] |
||||
|
) { |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
const role: Protobuf.Config.Config_DeviceConfig_Role = node.user.role ?? |
||||
|
Protobuf.Config.Config_DeviceConfig_Role.CLIENT; |
||||
|
if (!filterState.role.includes(role)) return false; |
||||
|
|
||||
|
const hwModel: Protobuf.Mesh.HardwareModel = node.user.hwModel ?? |
||||
|
Protobuf.Mesh.HardwareModel.UNSET; |
||||
|
if (!filterState.hwModel.includes(hwModel)) return false; |
||||
|
|
||||
|
return true; |
||||
|
}, |
||||
|
[defaultFilterValues], |
||||
|
); |
||||
|
|
||||
|
const isFilterDirty = useCallback( |
||||
|
( |
||||
|
current: FilterState, |
||||
|
overrides?: Partial<FilterState>, |
||||
|
): boolean => { |
||||
|
const base: FilterState = overrides |
||||
|
? { ...defaultFilterValues, ...overrides } |
||||
|
: defaultFilterValues; |
||||
|
|
||||
|
for (const key of Object.keys(base) as (keyof FilterState)[]) { |
||||
|
const currentValue = current[key]; |
||||
|
const defaultValue = base[key]; |
||||
|
|
||||
|
if (Array.isArray(defaultValue) && Array.isArray(currentValue)) { |
||||
|
if (!shallowEqualArray(currentValue, defaultValue)) { |
||||
|
return true; |
||||
|
} |
||||
|
} else if (currentValue !== defaultValue) { |
||||
|
return true; |
||||
|
} |
||||
|
} |
||||
|
|
||||
function isFilterDirty( |
return false; |
||||
current: FilterState, |
}, |
||||
overrides?: Partial<FilterState>, |
[defaultFilterValues], |
||||
): boolean { |
); |
||||
const base: FilterState = overrides |
|
||||
? { ...defaultFilterValues, ...overrides } |
|
||||
: defaultFilterValues; |
|
||||
|
|
||||
return (Object.keys(base) as (keyof FilterState)[]).some((key) => { |
|
||||
const curr = current[key]; |
|
||||
const def = base[key]; |
|
||||
return Array.isArray(def) && Array.isArray(curr) |
|
||||
? !shallowEqualArray(curr, def) |
|
||||
: curr !== def; |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
return { nodeFilter, defaultFilterValues, isFilterDirty }; |
return { nodeFilter, defaultFilterValues, isFilterDirty }; |
||||
} |
} |
||||
|
|||||
@ -1,142 +1,128 @@ |
|||||
import { describe, expect, it } from "vitest"; |
import { describe, expect, it } from "vitest"; |
||||
import { fireEvent, render, screen } from "@testing-library/react"; |
import { fireEvent, render, screen } from "@testing-library/react"; |
||||
import { Table } from "@components/generic/Table/index.tsx"; |
import { DataRow, Heading, Table } from "@components/generic/Table/index.tsx"; |
||||
import { TimeAgo } from "@components/generic/TimeAgo.tsx"; |
import { TimeAgo } from "@components/generic/TimeAgo.tsx"; |
||||
import { Mono } from "@components/generic/Mono.tsx"; |
import { Mono } from "@components/generic/Mono.tsx"; |
||||
// @ts-types="react"
|
// @ts-types="react"
|
||||
|
|
||||
describe("Generic Table", () => { |
describe("Generic Table", () => { |
||||
it("Can render an empty table.", () => { |
it("Can render an empty table.", () => { |
||||
render( |
render(<Table headings={[]} rows={[]} />); |
||||
<Table |
|
||||
headings={[]} |
|
||||
rows={[]} |
|
||||
/>, |
|
||||
); |
|
||||
expect(screen.getByRole("table")).toBeInTheDocument(); |
expect(screen.getByRole("table")).toBeInTheDocument(); |
||||
}); |
}); |
||||
|
|
||||
it("Can render a table with headers and no rows.", async () => { |
it("Can render a table with headers and no rows.", async () => { |
||||
render( |
const headings: Heading[] = [ |
||||
<Table |
{ title: "Short Name", sortable: true }, |
||||
headings={[ |
{ title: "Last Heard", sortable: true }, |
||||
{ title: "", type: "blank", sortable: false }, |
{ title: "Connection", sortable: true }, |
||||
{ title: "Short Name", type: "normal", sortable: true }, |
]; |
||||
{ title: "Long Name", type: "normal", sortable: true }, |
render(<Table headings={headings} rows={[]} />); |
||||
{ title: "Model", type: "normal", sortable: true }, |
|
||||
{ title: "MAC Address", type: "normal", sortable: true }, |
|
||||
{ title: "Last Heard", type: "normal", sortable: true }, |
|
||||
{ title: "SNR", type: "normal", sortable: true }, |
|
||||
{ title: "Encryption", type: "normal", sortable: false }, |
|
||||
{ title: "Connection", type: "normal", sortable: true }, |
|
||||
]} |
|
||||
rows={[]} |
|
||||
/>, |
|
||||
); |
|
||||
await screen.findByRole("table"); |
await screen.findByRole("table"); |
||||
expect(screen.getAllByRole("columnheader")).toHaveLength(9); |
expect(screen.getAllByRole("columnheader")).toHaveLength(3); |
||||
}); |
}); |
||||
|
|
||||
// A simplified version of the rows in pages/Nodes.tsx for testing purposes
|
// Mock data representing devices
|
||||
const mockDevicesWithShortNameAndConnection = [ |
const mockDevices = [ |
||||
{ |
{ |
||||
user: { shortName: "TST1" }, |
id: "TST1", |
||||
|
shortName: "TST1", |
||||
hopsAway: 1, |
hopsAway: 1, |
||||
lastHeard: Date.now() + 1000, |
lastHeard: Date.now() - 3000, |
||||
viaMqtt: false, |
viaMqtt: false, |
||||
}, |
}, |
||||
{ |
{ |
||||
user: { shortName: "TST2" }, |
id: "TST2", |
||||
|
shortName: "TST2", |
||||
hopsAway: 0, |
hopsAway: 0, |
||||
lastHeard: Date.now() + 4000, |
lastHeard: Date.now() - 1000, |
||||
viaMqtt: true, |
viaMqtt: true, |
||||
|
isFavorite: true, // Favorite device
|
||||
}, |
}, |
||||
{ |
{ |
||||
user: { shortName: "TST3" }, |
id: "TST3", |
||||
|
shortName: "TST3", |
||||
hopsAway: 4, |
hopsAway: 4, |
||||
lastHeard: Date.now(), |
lastHeard: Date.now() - 5000, |
||||
viaMqtt: false, |
viaMqtt: false, |
||||
}, |
}, |
||||
{ |
{ |
||||
user: { shortName: "TST4" }, |
id: "TST4", |
||||
|
shortName: "TST4", |
||||
hopsAway: 3, |
hopsAway: 3, |
||||
lastHeard: Date.now() + 2000, |
lastHeard: Date.now() - 2000, |
||||
viaMqtt: true, |
viaMqtt: true, |
||||
}, |
}, |
||||
]; |
]; |
||||
|
|
||||
const mockRows = mockDevicesWithShortNameAndConnection.map((node) => [ |
// Transform mock data into the format expected by the Table component
|
||||
<h1 data-testshortname key={node.user.shortName}>{node.user.shortName}</h1>, |
const mockRows: DataRow[] = mockDevices.map((node) => ({ |
||||
<Mono key="lastHeard" data-testheard> |
id: node.id, |
||||
<TimeAgo timestamp={node.lastHeard * 1000} /> |
isFavorite: node.isFavorite, |
||||
</Mono>, |
cells: [ |
||||
<Mono key="hops" data-testhops> |
{ |
||||
{node.lastHeard !== 0 |
content: <b data-testid="short-name">{node.shortName}</b>, |
||||
? node.viaMqtt === false && node.hopsAway === 0 |
sortValue: node.shortName, |
||||
? "Direct" |
}, |
||||
: `${node.hopsAway?.toString()} ${ |
{ |
||||
node.hopsAway ?? 0 > 1 ? "hops" : "hop" |
content: ( |
||||
} away` |
<Mono> |
||||
: "-"} |
<TimeAgo timestamp={node.lastHeard} /> |
||||
{node.viaMqtt === true ? ", via MQTT" : ""} |
</Mono> |
||||
</Mono>, |
), |
||||
]); |
sortValue: node.lastHeard, |
||||
|
}, |
||||
|
{ |
||||
|
content: ( |
||||
|
<Mono> |
||||
|
{node.lastHeard !== 0 |
||||
|
? node.viaMqtt === false && node.hopsAway === 0 |
||||
|
? "Direct" |
||||
|
: `${node.hopsAway} ${node.hopsAway > 1 ? "hops" : "hop"} away` |
||||
|
: "-"} |
||||
|
{node.viaMqtt ? ", via MQTT" : ""} |
||||
|
</Mono> |
||||
|
), |
||||
|
sortValue: node.hopsAway, |
||||
|
}, |
||||
|
], |
||||
|
})); |
||||
|
|
||||
it("Can sort rows appropriately.", async () => { |
const headings: Heading[] = [ |
||||
render( |
{ title: "Short Name", sortable: true }, |
||||
<Table |
{ title: "Last Heard", sortable: true }, |
||||
headings={[ |
{ title: "Connection", sortable: true }, |
||||
{ title: "Short Name", type: "normal", sortable: true }, |
]; |
||||
{ title: "Last Heard", type: "normal", sortable: true }, |
|
||||
{ title: "Connection", type: "normal", sortable: true }, |
it("Can sort rows, keeping favorites at the top", async () => { |
||||
]} |
render(<Table headings={headings} rows={mockRows} />); |
||||
rows={mockRows} |
|
||||
/>, |
|
||||
); |
|
||||
const renderedTable = await screen.findByRole("table"); |
const renderedTable = await screen.findByRole("table"); |
||||
const columnHeaders = screen.getAllByRole("columnheader"); |
const columnHeaders = screen.getAllByRole("columnheader"); |
||||
expect(columnHeaders).toHaveLength(3); |
expect(columnHeaders).toHaveLength(3); |
||||
|
|
||||
// Will be sorted "Last heard" "asc" by default
|
const getRenderedOrder = () => |
||||
expect( |
[...renderedTable.querySelectorAll("[data-testid='short-name']")].map( |
||||
[...renderedTable.querySelectorAll("[data-testshortname]")] |
(el) => el.textContent?.trim(), |
||||
.map((el) => el.textContent) |
); |
||||
.map((v) => v?.trim()) |
|
||||
.join(","), |
|
||||
) |
|
||||
.toMatch("TST2,TST4,TST1,TST3"); |
|
||||
|
|
||||
fireEvent.click(columnHeaders[0]); |
|
||||
|
|
||||
// Re-sort by Short Name asc
|
// Default sort: "Last Heard" desc. TST2 is favorite, so it's first.
|
||||
expect( |
// Then the rest are sorted by lastHeard timestamp (most recent first).
|
||||
[...renderedTable.querySelectorAll("[data-testshortname]")] |
// Order of timestamps: TST2 (latest, but favorite), TST4, TST1, TST3 (oldest).
|
||||
.map((el) => el.textContent) |
expect(getRenderedOrder()).toEqual(["TST2", "TST4", "TST1", "TST3"]); |
||||
.map((v) => v?.trim()) |
|
||||
.join(","), |
|
||||
) |
|
||||
.toMatch("TST1,TST2,TST3,TST4"); |
|
||||
|
|
||||
|
// Click "Short Name" to sort asc
|
||||
fireEvent.click(columnHeaders[0]); |
fireEvent.click(columnHeaders[0]); |
||||
|
// TST2 is favorite, so it's first. Then TST1, TST3, TST4 alphabetically.
|
||||
|
expect(getRenderedOrder()).toEqual(["TST2", "TST1", "TST3", "TST4"]); |
||||
|
|
||||
// Re-sort by Short Name desc
|
// Click "Short Name" again to sort desc
|
||||
expect( |
fireEvent.click(columnHeaders[0]); |
||||
[...renderedTable.querySelectorAll("[data-testshortname]")] |
// TST2 is favorite, so it's first. Then TST4, TST3, TST1 reverse alphabetically.
|
||||
.map((el) => el.textContent) |
expect(getRenderedOrder()).toEqual(["TST2", "TST4", "TST3", "TST1"]); |
||||
.map((v) => v?.trim()) |
|
||||
.join(","), |
|
||||
) |
|
||||
.toMatch("TST4,TST3,TST2,TST1"); |
|
||||
|
|
||||
|
// Click "Connection" to sort by hops asc
|
||||
fireEvent.click(columnHeaders[2]); |
fireEvent.click(columnHeaders[2]); |
||||
|
// TST2 is favorite (and also has 0 hops). Then sorted by hops: TST1 (1), TST4 (3), TST3 (4).
|
||||
// Re-sort by Hops Away
|
expect(getRenderedOrder()).toEqual(["TST2", "TST1", "TST4", "TST3"]); |
||||
expect( |
|
||||
[...renderedTable.querySelectorAll("[data-testshortname]")] |
|
||||
.map((el) => el.textContent) |
|
||||
.map((v) => v?.trim()) |
|
||||
.join(","), |
|
||||
) |
|
||||
.toMatch("TST2,TST1,TST4,TST3"); |
|
||||
}); |
}); |
||||
}); |
}); |
||||
|
|||||
@ -1,68 +0,0 @@ |
|||||
import { act, renderHook } from "@testing-library/react"; |
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"; |
|
||||
import { usePinnedItems } from "./usePinnedItems.ts"; |
|
||||
|
|
||||
const mockSetPinnedItems = vi.fn(); |
|
||||
const mockUseLocalStorage = vi.fn(); |
|
||||
|
|
||||
vi.mock("@core/hooks/useLocalStorage.ts", () => ({ |
|
||||
default: (...args) => mockUseLocalStorage(...args), |
|
||||
})); |
|
||||
|
|
||||
describe("usePinnedItems", () => { |
|
||||
beforeEach(() => { |
|
||||
vi.clearAllMocks(); |
|
||||
}); |
|
||||
|
|
||||
it("returns default pinnedItems and togglePinnedItem", () => { |
|
||||
mockUseLocalStorage.mockReturnValue([[], mockSetPinnedItems]); |
|
||||
|
|
||||
const { result } = renderHook(() => |
|
||||
usePinnedItems({ storageName: "test-storage" }) |
|
||||
); |
|
||||
|
|
||||
expect(result.current.pinnedItems).toEqual([]); |
|
||||
expect(typeof result.current.togglePinnedItem).toBe("function"); |
|
||||
}); |
|
||||
|
|
||||
it("adds an item if it's not already pinned", () => { |
|
||||
mockUseLocalStorage.mockReturnValue([["item1"], mockSetPinnedItems]); |
|
||||
|
|
||||
const { result } = renderHook(() => |
|
||||
usePinnedItems({ storageName: "test-storage" }) |
|
||||
); |
|
||||
|
|
||||
act(() => { |
|
||||
result.current.togglePinnedItem("item2"); |
|
||||
}); |
|
||||
|
|
||||
expect(mockSetPinnedItems).toHaveBeenCalledWith(expect.any(Function)); |
|
||||
|
|
||||
const updater = mockSetPinnedItems.mock.calls[0][0]; |
|
||||
const updated = updater(["item1"]); |
|
||||
|
|
||||
expect(updated).toEqual(["item1", "item2"]); |
|
||||
}); |
|
||||
|
|
||||
it("removes an item if it's already pinned", () => { |
|
||||
mockUseLocalStorage.mockReturnValue([ |
|
||||
["item1", "item2"], |
|
||||
mockSetPinnedItems, |
|
||||
]); |
|
||||
|
|
||||
const { result } = renderHook(() => |
|
||||
usePinnedItems({ storageName: "test-storage" }) |
|
||||
); |
|
||||
|
|
||||
act(() => { |
|
||||
result.current.togglePinnedItem("item1"); |
|
||||
}); |
|
||||
|
|
||||
expect(mockSetPinnedItems).toHaveBeenCalledWith(expect.any(Function)); |
|
||||
|
|
||||
const updater = mockSetPinnedItems.mock.calls[0][0]; |
|
||||
const updated = updater(["item1", "item2"]); |
|
||||
|
|
||||
expect(updated).toEqual(["item2"]); |
|
||||
}); |
|
||||
}); |
|
||||
@ -0,0 +1,82 @@ |
|||||
|
import { vi } from "vitest"; |
||||
|
import { type Device } from "./deviceStore.ts"; |
||||
|
import { Protobuf } from "@meshtastic/core"; |
||||
|
|
||||
|
/** |
||||
|
* You can spread this base mock in your tests and override only the |
||||
|
* properties relevant to a specific test case. |
||||
|
* |
||||
|
* @example |
||||
|
* vi.mocked(useDevice).mockReturnValue({ |
||||
|
* ...mockDeviceStore, |
||||
|
* getNode: (nodeNum) => mockNodes.get(nodeNum), |
||||
|
* }); |
||||
|
*/ |
||||
|
export const mockDeviceStore: Device = { |
||||
|
id: 0, |
||||
|
status: 5 as const, |
||||
|
channels: new Map(), |
||||
|
config: {} as Protobuf.LocalOnly.LocalConfig, |
||||
|
moduleConfig: {} as Protobuf.LocalOnly.LocalModuleConfig, |
||||
|
workingConfig: [], |
||||
|
workingModuleConfig: [], |
||||
|
hardware: {} as Protobuf.Mesh.MyNodeInfo, |
||||
|
metadata: new Map(), |
||||
|
traceroutes: new Map(), |
||||
|
nodeErrors: new Map(), |
||||
|
connection: undefined, |
||||
|
activeNode: 0, |
||||
|
waypoints: [], |
||||
|
pendingSettingsChanges: false, |
||||
|
messageDraft: "", |
||||
|
unreadCounts: new Map(), |
||||
|
nodesMap: new Map(), |
||||
|
dialog: { |
||||
|
import: false, |
||||
|
QR: false, |
||||
|
shutdown: false, |
||||
|
reboot: false, |
||||
|
rebootOTA: false, |
||||
|
deviceName: false, |
||||
|
nodeRemoval: false, |
||||
|
pkiBackup: false, |
||||
|
nodeDetails: false, |
||||
|
unsafeRoles: false, |
||||
|
refreshKeys: false, |
||||
|
deleteMessages: false, |
||||
|
}, |
||||
|
setStatus: vi.fn(), |
||||
|
setConfig: vi.fn(), |
||||
|
setModuleConfig: vi.fn(), |
||||
|
setWorkingConfig: vi.fn(), |
||||
|
setWorkingModuleConfig: vi.fn(), |
||||
|
setHardware: vi.fn(), |
||||
|
setActiveNode: vi.fn(), |
||||
|
setPendingSettingsChanges: vi.fn(), |
||||
|
addChannel: vi.fn(), |
||||
|
addWaypoint: vi.fn(), |
||||
|
addNodeInfo: vi.fn(), |
||||
|
addUser: vi.fn(), |
||||
|
addPosition: vi.fn(), |
||||
|
addConnection: vi.fn(), |
||||
|
addTraceRoute: vi.fn(), |
||||
|
addMetadata: vi.fn(), |
||||
|
removeNode: vi.fn(), |
||||
|
setDialogOpen: vi.fn(), |
||||
|
getDialogOpen: vi.fn().mockReturnValue(false), |
||||
|
processPacket: vi.fn(), |
||||
|
setMessageDraft: vi.fn(), |
||||
|
setNodeError: vi.fn(), |
||||
|
clearNodeError: vi.fn(), |
||||
|
getNodeError: vi.fn().mockReturnValue(undefined), |
||||
|
hasNodeError: vi.fn().mockReturnValue(false), |
||||
|
incrementUnread: vi.fn(), |
||||
|
resetUnread: vi.fn(), |
||||
|
getNodes: vi.fn().mockReturnValue([]), |
||||
|
getNodesLength: vi.fn().mockReturnValue(0), |
||||
|
getNode: vi.fn().mockReturnValue(undefined), |
||||
|
getMyNode: vi.fn(), |
||||
|
sendAdminMessage: vi.fn(), |
||||
|
updateFavorite: vi.fn(), |
||||
|
updateIgnored: vi.fn(), |
||||
|
}; |
||||
@ -0,0 +1,18 @@ |
|||||
|
export function intlSort<T extends PropertyKey>( |
||||
|
arr: T[], |
||||
|
order: "asc" | "desc" = "asc", |
||||
|
locale: Intl.Locale, |
||||
|
): T[] { |
||||
|
const collator = new Intl.Collator(locale, { sensitivity: "base" }); |
||||
|
|
||||
|
return arr.sort((a, b) => { |
||||
|
const stringA = String(a); |
||||
|
const stringB = String(b); |
||||
|
|
||||
|
if (order === "asc") { |
||||
|
return collator.compare(stringA, stringB); |
||||
|
} else { |
||||
|
return collator.compare(stringB, stringA); |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
@ -1,80 +0,0 @@ |
|||||
import { |
|
||||
createMemoryHistory, |
|
||||
createRouter, |
|
||||
Outlet, |
|
||||
RootRoute, |
|
||||
Route, |
|
||||
RouterProvider, |
|
||||
} from "@tanstack/react-router"; |
|
||||
import { render as rtlRender, RenderOptions } from "@testing-library/react"; |
|
||||
import type { FunctionComponent, ReactElement, ReactNode } from "react"; |
|
||||
|
|
||||
// a root route for the test router.
|
|
||||
const rootRoute = new RootRoute({ |
|
||||
component: () => ( |
|
||||
<> |
|
||||
<Outlet /> |
|
||||
</> |
|
||||
), |
|
||||
}); |
|
||||
|
|
||||
interface CustomRenderOptions extends Omit<RenderOptions, "wrapper"> { |
|
||||
initialEntries?: string[]; |
|
||||
ui?: ReactElement; |
|
||||
} |
|
||||
|
|
||||
let currentRouter: ReturnType<typeof createRouter> | null = null; |
|
||||
|
|
||||
/** |
|
||||
* Custom render function for testing components that need TanStack Router context. |
|
||||
* @param ui The main ReactElement to render (your component under test). |
|
||||
* @param options Custom render options including initialEntries for the router. |
|
||||
* @returns An object containing the testing-library render result and the router instance. |
|
||||
*/ |
|
||||
const customRender = ( |
|
||||
ui: ReactElement, |
|
||||
options: CustomRenderOptions = {}, |
|
||||
) => { |
|
||||
const { initialEntries = ["/"], ...renderOptions } = options; |
|
||||
|
|
||||
// A specific route that renders the component under test (ui).
|
|
||||
// It defaults to the first path in initialEntries or '/'.
|
|
||||
const testComponentRoute = new Route({ |
|
||||
getParentRoute: () => rootRoute, |
|
||||
path: initialEntries[0] || "/", |
|
||||
component: () => ui, // The component passed to render will be the element for this route
|
|
||||
}); |
|
||||
|
|
||||
const routeTree = rootRoute.addChildren([testComponentRoute]); |
|
||||
|
|
||||
const router = createRouter({ |
|
||||
history: createMemoryHistory({ initialEntries }), |
|
||||
routeTree, |
|
||||
// You can add default error components or other router options if needed for tests.
|
|
||||
// defaultErrorComponent: ({ error }) => <div>Test Error: {error.message}</div>,
|
|
||||
}); |
|
||||
|
|
||||
currentRouter = router; // Store the router instance for access in tests
|
|
||||
|
|
||||
const Wrapper: FunctionComponent<{ children?: ReactNode }> = ( |
|
||||
{ children }, |
|
||||
) => { |
|
||||
return ( |
|
||||
<> |
|
||||
<RouterProvider router={router} /> |
|
||||
{children} |
|
||||
</> |
|
||||
); |
|
||||
}; |
|
||||
|
|
||||
const renderResult = rtlRender(ui, { wrapper: Wrapper, ...renderOptions }); |
|
||||
|
|
||||
return { |
|
||||
...renderResult, |
|
||||
router, |
|
||||
}; |
|
||||
}; |
|
||||
|
|
||||
export * from "@testing-library/react"; |
|
||||
export { customRender as render }; |
|
||||
export const getTestRouter = () => currentRouter; |
|
||||
@ -1,81 +0,0 @@ |
|||||
import { beforeEach, describe, expect, it, vi } from "vitest"; |
|
||||
import { fireEvent, render, screen } from "@testing-library/react"; |
|
||||
import { MessagesPage } from "./Messages.tsx"; |
|
||||
import { useDevice } from "../core/stores/deviceStore.ts"; |
|
||||
import { Protobuf } from "@meshtastic/core"; |
|
||||
|
|
||||
vi.mock("../core/stores/deviceStore", () => ({ |
|
||||
useDevice: vi.fn(), |
|
||||
})); |
|
||||
|
|
||||
const mockUseDevice = { |
|
||||
channels: new Map([ |
|
||||
[0, { |
|
||||
index: 0, |
|
||||
settings: { name: "Primary" }, |
|
||||
role: Protobuf.Channel.Channel_Role.PRIMARY, |
|
||||
}], |
|
||||
]), |
|
||||
nodes: new Map([ |
|
||||
[0, { |
|
||||
num: 0, |
|
||||
user: { longName: "Test Node 0", shortName: "TN0", publicKey: "0000" }, |
|
||||
}], |
|
||||
[1111, { |
|
||||
num: 1111, |
|
||||
user: { longName: "Test Node 1", shortName: "TN1", publicKey: "12345" }, |
|
||||
}], |
|
||||
[2222, { |
|
||||
num: 2222, |
|
||||
user: { longName: "Test Node 2", shortName: "TN2", publicKey: "67890" }, |
|
||||
}], |
|
||||
[3333, { |
|
||||
num: 3333, |
|
||||
user: { longName: "Test Node 3", shortName: "TN3", publicKey: "11111" }, |
|
||||
}], |
|
||||
]), |
|
||||
hardware: { myNodeNum: 1 }, |
|
||||
messages: { broadcast: new Map(), direct: new Map() }, |
|
||||
metadata: new Map(), |
|
||||
unreadCounts: new Map([[1111, 3], [2222, 10]]), |
|
||||
resetUnread: vi.fn(), |
|
||||
hasNodeError: vi.fn(), |
|
||||
}; |
|
||||
|
|
||||
describe.skip("Messages Page", () => { |
|
||||
beforeEach(() => { |
|
||||
vi.mocked(useDevice).mockReturnValue(mockUseDevice); |
|
||||
}); |
|
||||
|
|
||||
it("sorts unreads to the top", () => { |
|
||||
render(<MessagesPage />); |
|
||||
const buttonOrder = screen.getAllByRole("button").filter((b) => |
|
||||
b.textContent.includes("Test Node") |
|
||||
); |
|
||||
expect(buttonOrder[0].textContent).toContain("TN2Test Node 210"); |
|
||||
expect(buttonOrder[1].textContent).toContain("TN1Test Node 13"); |
|
||||
expect(buttonOrder[2].textContent).toContain("TN0Test Node 0"); |
|
||||
expect(buttonOrder[3].textContent).toContain("TN3Test Node 3"); |
|
||||
}); |
|
||||
|
|
||||
it("updates unread when active chat changes", () => { |
|
||||
render(<MessagesPage />); |
|
||||
const nodeButton = |
|
||||
screen.getAllByRole("button").filter((b) => |
|
||||
b.textContent.includes("TN1Test Node 13") |
|
||||
)[0]; |
|
||||
fireEvent.click(nodeButton); |
|
||||
expect(mockUseDevice.resetUnread).toHaveBeenCalledWith(1111, 0); |
|
||||
}); |
|
||||
|
|
||||
it("does not update the incorrect node", () => { |
|
||||
render(<MessagesPage />); |
|
||||
const nodeButton = |
|
||||
screen.getAllByRole("button").filter((b) => |
|
||||
b.textContent.includes("TN1Test Node 1") |
|
||||
)[0]; |
|
||||
fireEvent.click(nodeButton); |
|
||||
expect(mockUseDevice.resetUnread).toHaveBeenCalledWith(1111, 0); |
|
||||
expect(mockUseDevice.unreadCounts.get(2222)).toBe(10); |
|
||||
}); |
|
||||
}); |
|
||||
@ -1,59 +0,0 @@ |
|||||
/* eslint-disable */ |
|
||||
|
|
||||
// @ts-nocheck
|
|
||||
|
|
||||
// noinspection JSUnusedGlobalSymbols
|
|
||||
|
|
||||
// This file was automatically generated by TanStack Router.
|
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
|
||||
|
|
||||
// Import Routes
|
|
||||
|
|
||||
import { Route as rootRoute } from './routes/__root' |
|
||||
|
|
||||
// Create/Update Routes
|
|
||||
|
|
||||
// Populate the FileRoutesByPath interface
|
|
||||
|
|
||||
declare module '@tanstack/react-router' { |
|
||||
interface FileRoutesByPath {} |
|
||||
} |
|
||||
|
|
||||
// Create and export the route tree
|
|
||||
|
|
||||
export interface FileRoutesByFullPath {} |
|
||||
|
|
||||
export interface FileRoutesByTo {} |
|
||||
|
|
||||
export interface FileRoutesById { |
|
||||
__root__: typeof rootRoute |
|
||||
} |
|
||||
|
|
||||
export interface FileRouteTypes { |
|
||||
fileRoutesByFullPath: FileRoutesByFullPath |
|
||||
fullPaths: never |
|
||||
fileRoutesByTo: FileRoutesByTo |
|
||||
to: never |
|
||||
id: '__root__' |
|
||||
fileRoutesById: FileRoutesById |
|
||||
} |
|
||||
|
|
||||
export interface RootRouteChildren {} |
|
||||
|
|
||||
const rootRouteChildren: RootRouteChildren = {} |
|
||||
|
|
||||
export const routeTree = rootRoute |
|
||||
._addFileChildren(rootRouteChildren) |
|
||||
._addFileTypes<FileRouteTypes>() |
|
||||
|
|
||||
/* ROUTE_MANIFEST_START |
|
||||
{ |
|
||||
"routes": { |
|
||||
"__root__": { |
|
||||
"filePath": "__root.tsx", |
|
||||
"children": [] |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
ROUTE_MANIFEST_END */ |
|
||||
@ -0,0 +1,37 @@ |
|||||
|
import { ReactElement } from "react"; |
||||
|
import { render, RenderOptions } from "@testing-library/react"; |
||||
|
import { |
||||
|
createMemoryHistory, |
||||
|
createRouter, |
||||
|
RouterProvider, |
||||
|
} from "@tanstack/react-router"; |
||||
|
import "../i18n/config.ts"; |
||||
|
import { routeTree } from "../routeTree.gen.ts"; |
||||
|
|
||||
|
import { DeviceWrapper } from "@app/DeviceWrapper.tsx"; |
||||
|
|
||||
|
const Providers = () => { |
||||
|
const memoryHistory = createMemoryHistory({ |
||||
|
initialEntries: ["/"], |
||||
|
}); |
||||
|
|
||||
|
const router = createRouter({ |
||||
|
routeTree, |
||||
|
history: memoryHistory, |
||||
|
}); |
||||
|
|
||||
|
return ( |
||||
|
<DeviceWrapper> |
||||
|
<RouterProvider router={router} /> |
||||
|
</DeviceWrapper> |
||||
|
); |
||||
|
}; |
||||
|
|
||||
|
const renderWithProviders = ( |
||||
|
ui: ReactElement, |
||||
|
options?: Omit<RenderOptions, "wrapper">, |
||||
|
) => render(ui, { wrapper: Providers, ...options }); |
||||
|
|
||||
|
export * from "@testing-library/react"; |
||||
|
|
||||
|
export { renderWithProviders as render }; |
||||
@ -0,0 +1,11 @@ |
|||||
|
/// <reference types="vite/client" />
|
||||
|
|
||||
|
interface ImportMetaEnv { |
||||
|
readonly env: { |
||||
|
readonly VITE_COMMIT_HASH: string; |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
interface ImportMeta { |
||||
|
readonly env: ImportMetaEnv; |
||||
|
} |
||||
Loading…
Reference in new issue