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:ui": "VITE_APP_VERSION=development deno run -A npm:vite dev", |
|||
"test": "deno run -A npm:vitest", |
|||
"check": "deno check", |
|||
"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/ ." |
|||
}, |
|||
@ -35,10 +36,11 @@ |
|||
"homepage": "https://meshtastic.org", |
|||
"dependencies": { |
|||
"@bufbuild/protobuf": "^2.2.5", |
|||
"@meshtastic/core": "npm:@jsr/[email protected]", |
|||
"@meshtastic/transport-http": "npm:@jsr/[email protected]", |
|||
"@meshtastic/transport-web-bluetooth": "npm:@jsr/[email protected]", |
|||
"@meshtastic/transport-web-serial": "npm:@jsr/[email protected]", |
|||
"@meshtastic/core": "npm:@jsr/[email protected]", |
|||
"@meshtastic/js": "npm:@jsr/[email protected]", |
|||
"@meshtastic/transport-http": "npm:@jsr/meshtastic__transport-http", |
|||
"@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", |
|||
"@radix-ui/react-accordion": "^1.2.8", |
|||
"@radix-ui/react-checkbox": "^1.2.3", |
|||
@ -60,6 +62,7 @@ |
|||
"@tanstack/react-router-devtools": "^1.120.16", |
|||
"@tanstack/router-devtools": "^1.120.15", |
|||
"@turf/turf": "^7.2.0", |
|||
"@types/web-bluetooth": "^0.0.21", |
|||
"base64-js": "^1.5.1", |
|||
"class-variance-authority": "^0.7.1", |
|||
"clsx": "^2.1.1", |
|||
@ -81,10 +84,8 @@ |
|||
"react-map-gl": "8.0.4", |
|||
"react-qrcode-logo": "^3.0.0", |
|||
"rfc4648": "^1.5.4", |
|||
"vite-plugin-i18n-ally": "^6.0.1", |
|||
"vite-plugin-node-polyfills": "^0.23.0", |
|||
"zod": "^3.25.0", |
|||
"zustand": "5.0.4" |
|||
"zod": "^3.25.62", |
|||
"zustand": "5.0.5" |
|||
}, |
|||
"devDependencies": { |
|||
"@tailwindcss/postcss": "^4.1.5", |
|||
@ -93,13 +94,12 @@ |
|||
"@testing-library/react": "^16.3.0", |
|||
"@testing-library/user-event": "^14.6.1", |
|||
"@types/chrome": "^0.0.318", |
|||
"@types/js-cookie": "^3.0.6", |
|||
"@types/node": "^22.15.3", |
|||
"@types/react": "^19.1.2", |
|||
"@types/react-dom": "^19.1.3", |
|||
"@types/serviceworker": "^0.0.133", |
|||
"@types/js-cookie": "^3.0.6", |
|||
"@types/w3c-web-serial": "^1.0.8", |
|||
"@types/web-bluetooth": "^0.0.21", |
|||
"@vitejs/plugin-react": "^4.4.1", |
|||
"autoprefixer": "^10.4.21", |
|||
"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 { numberToHexUnpadded } from "@noble/curves/abstract/utils"; |
|||
import { useMemo } from "react"; |
|||
import { useCallback, useMemo } from "react"; |
|||
|
|||
export type FilterState = { |
|||
nodeName: string; |
|||
hopsAway: [number, number]; |
|||
lastHeard: [number, number]; |
|||
isFavorite: boolean | undefined; // undefined -> don't filter
|
|||
viaMqtt: boolean | undefined; // undefined -> don't filter
|
|||
isFavorite: boolean | undefined; |
|||
viaMqtt: boolean | undefined; |
|||
snr: [number, number]; |
|||
channelUtilization: [number, number]; |
|||
airUtilTx: [number, number]; |
|||
batteryLevel: [number, number]; |
|||
voltage: [number, number]; |
|||
role: (Protobuf.Config.Config_DeviceConfig_Role)[]; |
|||
hwModel: (Protobuf.Mesh.HardwareModel)[]; |
|||
role: Protobuf.Config.Config_DeviceConfig_Role[]; |
|||
hwModel: Protobuf.Mesh.HardwareModel[]; |
|||
}; |
|||
|
|||
export function useFilterNode() { |
|||
const defaultFilterValues = useMemo<FilterState>(() => ({ |
|||
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", |
|||
), |
|||
}), []); |
|||
|
|||
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]) { |
|||
const shallowEqualArray = <T>(a: T[], b: T[]): boolean => { |
|||
if (a.length !== b.length) { |
|||
return false; |
|||
} |
|||
for (let i = 0; i < a.length; i++) { |
|||
if (a[i] !== b[i]) { |
|||
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
|
|||
function shallowEqualArray(a: any[], b: any[]) { |
|||
return a.length === b.length && a.every((v, i) => v === b[i]); |
|||
} |
|||
export function useFilterNode() { |
|||
const defaultFilterValues = useMemo<FilterState>( |
|||
() => ({ |
|||
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( |
|||
current: FilterState, |
|||
overrides?: Partial<FilterState>, |
|||
): 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 false; |
|||
}, |
|||
[defaultFilterValues], |
|||
); |
|||
|
|||
return { nodeFilter, defaultFilterValues, isFilterDirty }; |
|||
} |
|||
|
|||
@ -1,142 +1,128 @@ |
|||
import { describe, expect, it } from "vitest"; |
|||
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 { Mono } from "@components/generic/Mono.tsx"; |
|||
// @ts-types="react"
|
|||
|
|||
describe("Generic Table", () => { |
|||
it("Can render an empty table.", () => { |
|||
render( |
|||
<Table |
|||
headings={[]} |
|||
rows={[]} |
|||
/>, |
|||
); |
|||
render(<Table headings={[]} rows={[]} />); |
|||
expect(screen.getByRole("table")).toBeInTheDocument(); |
|||
}); |
|||
|
|||
it("Can render a table with headers and no rows.", async () => { |
|||
render( |
|||
<Table |
|||
headings={[ |
|||
{ title: "", type: "blank", sortable: false }, |
|||
{ title: "Short Name", type: "normal", sortable: true }, |
|||
{ title: "Long Name", type: "normal", sortable: true }, |
|||
{ 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={[]} |
|||
/>, |
|||
); |
|||
const headings: Heading[] = [ |
|||
{ title: "Short Name", sortable: true }, |
|||
{ title: "Last Heard", sortable: true }, |
|||
{ title: "Connection", sortable: true }, |
|||
]; |
|||
render(<Table headings={headings} rows={[]} />); |
|||
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
|
|||
const mockDevicesWithShortNameAndConnection = [ |
|||
// Mock data representing devices
|
|||
const mockDevices = [ |
|||
{ |
|||
user: { shortName: "TST1" }, |
|||
id: "TST1", |
|||
shortName: "TST1", |
|||
hopsAway: 1, |
|||
lastHeard: Date.now() + 1000, |
|||
lastHeard: Date.now() - 3000, |
|||
viaMqtt: false, |
|||
}, |
|||
{ |
|||
user: { shortName: "TST2" }, |
|||
id: "TST2", |
|||
shortName: "TST2", |
|||
hopsAway: 0, |
|||
lastHeard: Date.now() + 4000, |
|||
lastHeard: Date.now() - 1000, |
|||
viaMqtt: true, |
|||
isFavorite: true, // Favorite device
|
|||
}, |
|||
{ |
|||
user: { shortName: "TST3" }, |
|||
id: "TST3", |
|||
shortName: "TST3", |
|||
hopsAway: 4, |
|||
lastHeard: Date.now(), |
|||
lastHeard: Date.now() - 5000, |
|||
viaMqtt: false, |
|||
}, |
|||
{ |
|||
user: { shortName: "TST4" }, |
|||
id: "TST4", |
|||
shortName: "TST4", |
|||
hopsAway: 3, |
|||
lastHeard: Date.now() + 2000, |
|||
lastHeard: Date.now() - 2000, |
|||
viaMqtt: true, |
|||
}, |
|||
]; |
|||
|
|||
const mockRows = mockDevicesWithShortNameAndConnection.map((node) => [ |
|||
<h1 data-testshortname key={node.user.shortName}>{node.user.shortName}</h1>, |
|||
<Mono key="lastHeard" data-testheard> |
|||
<TimeAgo timestamp={node.lastHeard * 1000} /> |
|||
</Mono>, |
|||
<Mono key="hops" data-testhops> |
|||
{node.lastHeard !== 0 |
|||
? node.viaMqtt === false && node.hopsAway === 0 |
|||
? "Direct" |
|||
: `${node.hopsAway?.toString()} ${ |
|||
node.hopsAway ?? 0 > 1 ? "hops" : "hop" |
|||
} away` |
|||
: "-"} |
|||
{node.viaMqtt === true ? ", via MQTT" : ""} |
|||
</Mono>, |
|||
]); |
|||
// Transform mock data into the format expected by the Table component
|
|||
const mockRows: DataRow[] = mockDevices.map((node) => ({ |
|||
id: node.id, |
|||
isFavorite: node.isFavorite, |
|||
cells: [ |
|||
{ |
|||
content: <b data-testid="short-name">{node.shortName}</b>, |
|||
sortValue: node.shortName, |
|||
}, |
|||
{ |
|||
content: ( |
|||
<Mono> |
|||
<TimeAgo timestamp={node.lastHeard} /> |
|||
</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 () => { |
|||
render( |
|||
<Table |
|||
headings={[ |
|||
{ title: "Short Name", type: "normal", sortable: true }, |
|||
{ title: "Last Heard", type: "normal", sortable: true }, |
|||
{ title: "Connection", type: "normal", sortable: true }, |
|||
]} |
|||
rows={mockRows} |
|||
/>, |
|||
); |
|||
const headings: Heading[] = [ |
|||
{ title: "Short Name", sortable: true }, |
|||
{ title: "Last Heard", sortable: true }, |
|||
{ title: "Connection", sortable: true }, |
|||
]; |
|||
|
|||
it("Can sort rows, keeping favorites at the top", async () => { |
|||
render(<Table headings={headings} rows={mockRows} />); |
|||
const renderedTable = await screen.findByRole("table"); |
|||
const columnHeaders = screen.getAllByRole("columnheader"); |
|||
expect(columnHeaders).toHaveLength(3); |
|||
|
|||
// Will be sorted "Last heard" "asc" by default
|
|||
expect( |
|||
[...renderedTable.querySelectorAll("[data-testshortname]")] |
|||
.map((el) => el.textContent) |
|||
.map((v) => v?.trim()) |
|||
.join(","), |
|||
) |
|||
.toMatch("TST2,TST4,TST1,TST3"); |
|||
|
|||
fireEvent.click(columnHeaders[0]); |
|||
const getRenderedOrder = () => |
|||
[...renderedTable.querySelectorAll("[data-testid='short-name']")].map( |
|||
(el) => el.textContent?.trim(), |
|||
); |
|||
|
|||
// Re-sort by Short Name asc
|
|||
expect( |
|||
[...renderedTable.querySelectorAll("[data-testshortname]")] |
|||
.map((el) => el.textContent) |
|||
.map((v) => v?.trim()) |
|||
.join(","), |
|||
) |
|||
.toMatch("TST1,TST2,TST3,TST4"); |
|||
// Default sort: "Last Heard" desc. TST2 is favorite, so it's first.
|
|||
// Then the rest are sorted by lastHeard timestamp (most recent first).
|
|||
// Order of timestamps: TST2 (latest, but favorite), TST4, TST1, TST3 (oldest).
|
|||
expect(getRenderedOrder()).toEqual(["TST2", "TST4", "TST1", "TST3"]); |
|||
|
|||
// Click "Short Name" to sort asc
|
|||
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
|
|||
expect( |
|||
[...renderedTable.querySelectorAll("[data-testshortname]")] |
|||
.map((el) => el.textContent) |
|||
.map((v) => v?.trim()) |
|||
.join(","), |
|||
) |
|||
.toMatch("TST4,TST3,TST2,TST1"); |
|||
// Click "Short Name" again to sort desc
|
|||
fireEvent.click(columnHeaders[0]); |
|||
// TST2 is favorite, so it's first. Then TST4, TST3, TST1 reverse alphabetically.
|
|||
expect(getRenderedOrder()).toEqual(["TST2", "TST4", "TST3", "TST1"]); |
|||
|
|||
// Click "Connection" to sort by hops asc
|
|||
fireEvent.click(columnHeaders[2]); |
|||
|
|||
// Re-sort by Hops Away
|
|||
expect( |
|||
[...renderedTable.querySelectorAll("[data-testshortname]")] |
|||
.map((el) => el.textContent) |
|||
.map((v) => v?.trim()) |
|||
.join(","), |
|||
) |
|||
.toMatch("TST2,TST1,TST4,TST3"); |
|||
// TST2 is favorite (and also has 0 hops). Then sorted by hops: TST1 (1), TST4 (3), TST3 (4).
|
|||
expect(getRenderedOrder()).toEqual(["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