diff --git a/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx b/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx index d8d1bea9..2b2edc23 100644 --- a/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx +++ b/src/components/Dialog/NodeDetailsDialog/NodeDetailsDialog.test.tsx @@ -1,12 +1,17 @@ -import { describe, it, vi, expect, beforeEach, Mock } from "vitest"; +import { describe, it, vi, expect, beforeEach } 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 { Protobuf } from "@meshtastic/core"; vi.mock("@core/stores/deviceStore"); vi.mock("@core/stores/appStore"); +const mockUseDevice = vi.mocked(useDevice); +const mockUseAppStore = vi.mocked(useAppStore); + + describe("NodeDetailsDialog", () => { const mockDevice = { num: 1234, @@ -29,17 +34,21 @@ describe("NodeDetailsDialog", () => { voltage: 4.2, uptimeSeconds: 3600, }, - }; + } as unknown as Protobuf.Mesh.NodeInfo; beforeEach(() => { - // Reset mocks before each test vi.resetAllMocks(); - (useDevice as Mock).mockReturnValue({ - nodes: new Map([[1234, mockDevice]]), + mockUseDevice.mockReturnValue({ + getNode: (nodeNum: number) => { + if (nodeNum === 1234) { + return mockDevice; + } + return undefined; + }, }); - (useAppStore as unknown as Mock).mockReturnValue({ + mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234, }); }); @@ -47,27 +56,87 @@ describe("NodeDetailsDialog", () => { it("renders node details correctly", () => { render( { }} />); - expect(screen.getByText(/Node Details for Test Node/i)).toBeInTheDocument(); + 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(/Coordinates:/i)).toBeInTheDocument(); - expect(screen.getByText("45, -75")).toBeInTheDocument(); - expect(screen.getByText(/Altitude: 200m/i)).toBeInTheDocument(); - expect(screen.getByText(/Role:/i)).toBeInTheDocument(); + + expect(screen.getByText(/All Raw Metrics:/i)).toBeInTheDocument(); + }); it("renders null if device is not found", () => { - (useDevice as Mock).mockReturnValue({ - nodes: new Map(), + const requestedNodeNum = 5678; + + mockUseAppStore.mockReturnValue({ + nodeNumDetails: requestedNodeNum, }); - render( { }} />); + mockUseDevice.mockReturnValue({ + getNode: (nodeNum: number) => { + if (nodeNum === requestedNodeNum) { + return undefined; + } + if (nodeNum === 1234) { + return mockDevice; + } + return undefined; + }, + }); + + const { container } = render( { }} />); + + expect(container.firstChild).toBeNull(); expect(screen.queryByText(/Node Details for/i)).not.toBeInTheDocument(); }); -}); + + it("renders correctly when position is missing", () => { + const nodeWithoutPosition = { ...mockDevice, position: undefined }; + mockUseDevice.mockReturnValue({ getNode: () => nodeWithoutPosition }); + mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234 }); + + render( { }} />); + + 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 = { ...mockDevice, deviceMetrics: undefined }; + mockUseDevice.mockReturnValue({ getNode: () => nodeWithoutMetrics }); + mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234 }); + + render( { }} />); + + 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 = { ...mockDevice, lastHeard: 0 }; + mockUseDevice.mockReturnValue({ getNode: () => nodeNeverHeard }); + mockUseAppStore.mockReturnValue({ nodeNumDetails: 1234 }); + + render( { }} />); + + expect(screen.getByText(/Last Heard: Never/i)).toBeInTheDocument(); + }); + +}); \ No newline at end of file diff --git a/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.test.tsx b/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.test.tsx index a1733d95..587a30f5 100644 --- a/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.test.tsx +++ b/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.test.tsx @@ -1,102 +1,97 @@ -import { render, screen, fireEvent } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi, Mock } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { DeviceContext, useDeviceStore } from "@core/stores/deviceStore.ts"; import { RefreshKeysDialog } from "./RefreshKeysDialog.tsx"; +import { useMessageStore } from "@core/stores/messageStore.ts"; import { useRefreshKeysDialog } from "./useRefreshKeysDialog.ts"; -import { useMessageStore } from "@core/stores/messageStore.ts"; // Import for mocking -import { useDevice } from "@core/stores/deviceStore.ts"; // Import for mocking +import { expect, test, vi, beforeEach, afterEach } from 'vitest'; import { Protobuf } from "@meshtastic/core"; - -vi.mock("@core/stores/messageStore.ts", () => ({ - useMessageStore: vi.fn(), -})); - -const mockNodeWithError: Partial = { - user: { longName: "Test Node Long", shortName: "TNL", id: 456 }, -}; -const mockNodes = new Map([[456, mockNodeWithError]]); -const mockNodeErrors = new Map([[123, { node: 456 }]]); - -vi.mock("@core/stores/deviceStore.ts", () => ({ - useDevice: vi.fn(), -})); - -const mockHandleCloseDialog = vi.fn(); -const mockHandleNodeRemove = vi.fn(); -vi.mock("./useRefreshKeysDialog.ts", () => ({ - useRefreshKeysDialog: vi.fn(() => ({ - handleCloseDialog: mockHandleCloseDialog, - handleNodeRemove: mockHandleNodeRemove, - })), -})); - -describe("RefreshKeysDialog Component", () => { - let onOpenChangeMock: Mock; - - beforeEach(() => { - vi.clearAllMocks(); - onOpenChangeMock = vi.fn(); - - vi.mocked(useMessageStore).mockReturnValue({ activeChat: 123 }); - vi.mocked(useDevice).mockReturnValue({ - nodeErrors: mockNodeErrors, - nodes: mockNodes, - }); - vi.mocked(useRefreshKeysDialog).mockReturnValue({ - handleCloseDialog: mockHandleCloseDialog, - handleNodeRemove: mockHandleNodeRemove, - }); +vi.mock("@core/stores/messageStore"); +vi.mock("./useRefreshKeysDialog"); + +const mockUseMessageStore = vi.mocked(useMessageStore); +const mockUseRefreshKeysDialog = vi.mocked(useRefreshKeysDialog); + +const getInitialState = () => useDeviceStore.getInitialState?.() ?? { devices: new Map(), remoteDevices: new Map() }; + +beforeEach(() => { + useDeviceStore.setState(getInitialState(), true); + vi.clearAllMocks(); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +test("renders dialog when there is a node error for the active chat", () => { + const deviceId = 1; + const nodeWithErrorNum = 12345; + const activeChatNum = nodeWithErrorNum; + + const deviceStore = useDeviceStore.getState().addDevice(deviceId); + + deviceStore.addNodeInfo({ + num: nodeWithErrorNum, + user: { + id: nodeWithErrorNum.toString(), + publicKey: new Uint8Array(0), + hwModel: Protobuf.Mesh.HardwareModel.HELTEC_V3, + longName: "Problem Node Long", + shortName: "ProbNode", + isLicensed: false, + macaddr: new Uint8Array(0) + }, + lastHeard: Date.now() / 1000, + snr: 10 + } as Protobuf.Mesh.NodeInfo); + + deviceStore.setNodeError(activeChatNum, "PKI_MISMATCH"); + + const updatedDeviceState = useDeviceStore.getState().getDevice(deviceId); + if (!updatedDeviceState) { + throw new Error("Failed to get updated device state from store for provider"); + } + + mockUseMessageStore.mockReturnValue({ activeChat: activeChatNum }); + const mockHandleClose = vi.fn(); + const mockHandleRemove = vi.fn(); + mockUseRefreshKeysDialog.mockReturnValue({ + handleCloseDialog: mockHandleClose, + handleNodeRemove: mockHandleRemove, }); - it("should render the dialog with dynamic content when open and data is available", () => { - render(); - - expect(screen.getByText(`Keys Mismatch - ${mockNodeWithError?.user?.longName}`)).toBeInTheDocument(); - expect(screen.getByText(new RegExp(`${mockNodeWithError?.user?.longName}.*${mockNodeWithError?.user?.shortName}`))).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /request new keys/i })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /dismiss/i })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /Close/i })).toBeInTheDocument(); - }); + render( + + + + ); - it("should call handleNodeRemove when 'Request New Keys' button is clicked", () => { - render(); - fireEvent.click(screen.getByRole('button', { name: /request new keys/i })); - expect(mockHandleNodeRemove).toHaveBeenCalledTimes(1); - }); + expect(screen.getByText(/Keys Mismatch - Problem Node Long/)).toBeInTheDocument(); + expect(screen.getByText(/Your node is unable to send a direct message to node: Problem Node Long \(ProbNode\)/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Request New Keys" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Dismiss" })).toBeInTheDocument(); +}); - it("should call handleCloseDialog when 'Dismiss' button is clicked", () => { - render(); - fireEvent.click(screen.getByRole('button', { name: /dismiss/i })); - expect(mockHandleCloseDialog).toHaveBeenCalledTimes(1); - }); +test("does not render dialog if no error exists for active chat", () => { + const deviceId = 1; + const activeChatNum = 54321; - it("should call handleCloseDialog when the explicit DialogClose button is clicked", () => { - render(); - fireEvent.click(screen.getByRole('button', { name: /close/i })); // Use the aria-label - expect(mockHandleCloseDialog).toHaveBeenCalledTimes(1); - }); + useDeviceStore.getState().addDevice(deviceId); + const currentDeviceState = useDeviceStore.getState().getDevice(deviceId); + if (!currentDeviceState) throw new Error("Device not found"); - it("should not render the dialog when open is false", () => { - render(); - expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + mockUseMessageStore.mockReturnValue({ activeChat: activeChatNum }); + mockUseRefreshKeysDialog.mockReturnValue({ + handleCloseDialog: vi.fn(), + handleNodeRemove: vi.fn(), }); - it("should render null if nodeErrorNum is not found for activeChat", () => { - vi.mocked(useDevice).mockReturnValue({ - nodeErrors: new Map(), - nodes: mockNodes, - }); - const { container } = render(); - expect(container.firstChild).toBeNull(); - }); + const { container } = render( + + + + ); - it("should render null if nodeWithError is not found for nodeErrorNum.node", () => { - vi.mocked(useDevice).mockReturnValue({ - nodeErrors: mockNodeErrors, - nodes: new Map(), - }); - const { container } = render(); - expect(container.firstChild).toBeNull(); - }); -}); \ No newline at end of file + expect(container.firstChild).toBeNull(); +}); diff --git a/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx b/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx index e39c446b..d32cbfdc 100644 --- a/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx +++ b/src/components/Dialog/RefreshKeysDialog/RefreshKeysDialog.tsx @@ -27,12 +27,7 @@ export const RefreshKeysDialog = ({ open, onOpenChange }: RefreshKeysDialogProps return null; } - const nodeWithError = getNode(nodeErrorNum?.node ?? 0); - - if (!nodeWithError) { - console.error("Node with error not found"); - return null; - } + const nodeWithError = getNode(nodeErrorNum.node); const text = { title: `Keys Mismatch - ${nodeWithError?.user?.longName ?? ""}`, diff --git a/src/components/PageComponents/Messages/ChannelChat.tsx b/src/components/PageComponents/Messages/ChannelChat.tsx index 78f0b42b..29715b88 100644 --- a/src/components/PageComponents/Messages/ChannelChat.tsx +++ b/src/components/PageComponents/Messages/ChannelChat.tsx @@ -8,7 +8,7 @@ export interface ChannelChatProps { } const EmptyState = () => ( -
+
No Messages
@@ -24,9 +24,10 @@ export const ChannelChat = ({ const scrollContainer = scrollContainerRef.current; if (!scrollContainer) return; - const isNearBottom = scrollContainer.scrollTop < 100; + const scrollThreshold = 50; // How close to bottom to trigger smooth scroll + const isNearBottom = scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight < scrollThreshold; - if (isNearBottom || behavior === 'instant') { + if (behavior === 'instant' || isNearBottom) { messagesEndRef.current?.scrollIntoView({ behavior }); } }, []); @@ -37,25 +38,21 @@ export const ChannelChat = ({ } }, [messages, scrollToBottom]); - useEffect(() => { - if (messages.length > 0) { - scrollToBottom('instant'); - } - }, [scrollToBottom, messages.length]); - if (!messages?.length) { return (
    +
); } + return (
    {messages?.map((message) => { diff --git a/src/components/PageComponents/Messages/MessageInput.tsx b/src/components/PageComponents/Messages/MessageInput.tsx index f64dbc91..eca96ca3 100644 --- a/src/components/PageComponents/Messages/MessageInput.tsx +++ b/src/components/PageComponents/Messages/MessageInput.tsx @@ -3,9 +3,11 @@ import { Input } from "@components/UI/Input.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; import type { Types } from "@meshtastic/core"; import { SendIcon } from "lucide-react"; -import { startTransition, useCallback, useMemo, useState } from "react"; +import { + startTransition, useCallback, // @ts-types="react" + useDeferredValue, useState +} from "react"; import { MessageState, MessageType, useMessageStore } from "@core/stores/messageStore.ts"; -import { debounce } from "@core/utils/debounce.ts"; export interface MessageInputProps { to: Types.Destination; @@ -24,12 +26,9 @@ export const MessageInput = ({ const [localDraft, setLocalDraft] = useState(getDraft(to)); const [messageBytes, setMessageBytes] = useState(0); - const debouncedSetMessageDraft = useMemo( - () => debounce((value: string) => setDraft(to, value), 300), - [setDraft, to] - ); - const calculateBytes = (text: string) => new Blob([text]).size; + const deferredBytes = useDeferredValue(calculateBytes(localDraft)); + const chatType = to === MessageType.Broadcast ? MessageType.Broadcast : MessageType.Direct; @@ -52,12 +51,15 @@ export const MessageInput = ({ const handleInputChange = (e: React.ChangeEvent) => { const newValue = e.target.value; - const byteLength = calculateBytes(newValue); + const byteLength = deferredBytes if (byteLength <= maxBytes) { setLocalDraft(newValue); - debouncedSetMessageDraft(newValue); setMessageBytes(byteLength); + + startTransition(() => { + setDraft(to, newValue); + }); } }; diff --git a/src/components/PageComponents/Messages/TraceRoute.test.tsx b/src/components/PageComponents/Messages/TraceRoute.test.tsx index 977ef315..39443acb 100644 --- a/src/components/PageComponents/Messages/TraceRoute.test.tsx +++ b/src/components/PageComponents/Messages/TraceRoute.test.tsx @@ -1,30 +1,33 @@ -import { describe, it, expect, vi, beforeEach, Mock } from "vitest"; +import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; import { TraceRoute } from "@components/PageComponents/Messages/TraceRoute.tsx"; import { useDevice } from "@core/stores/deviceStore.ts"; +import type { Protobuf } from "@meshtastic/core"; vi.mock("@core/stores/deviceStore"); describe("TraceRoute", () => { - const mockNodes = new Map([ + const mockNodes = new Map([ [ 1, - { num: 1, user: { longName: "Node A" } }, + { num: 1, user: { longName: "Node A" } } as Protobuf.Mesh.NodeInfo, ], [ 2, - { num: 2, user: { longName: "Node B" } }, + { num: 2, user: { longName: "Node B" } } as Protobuf.Mesh.NodeInfo, ], [ 3, - { num: 3, user: { longName: "Node C" } }, + { num: 3, user: { longName: "Node C" } } as Protobuf.Mesh.NodeInfo, ], ]); beforeEach(() => { vi.resetAllMocks(); - (useDevice as Mock).mockReturnValue({ - nodes: mockNodes, + vi.mocked(useDevice).mockReturnValue({ + getNode: (nodeNum: number): Protobuf.Mesh.NodeInfo | undefined => { + return mockNodes.get(nodeNum); + }, }); }); @@ -38,17 +41,16 @@ describe("TraceRoute", () => { /> ); - expect(screen.getByText("Route to destination:")).toBeInTheDocument(); + expect(screen.getAllByText("Source Node")).toHaveLength(1); expect(screen.getByText("Destination Node")).toBeInTheDocument(); expect(screen.getByText("Node A")).toBeInTheDocument(); expect(screen.getByText("Node B")).toBeInTheDocument(); - expect(screen.getAllByText(/↓/)).toHaveLength(3); // startNode + 2 hops + expect(screen.getAllByText(/↓/)).toHaveLength(3); expect(screen.getByText("↓ 10dB")).toBeInTheDocument(); expect(screen.getByText("↓ 20dB")).toBeInTheDocument(); expect(screen.getByText("↓ 30dB")).toBeInTheDocument(); - expect(screen.getByText("Source Node")).toBeInTheDocument(); }); it("renders the route back when provided", () => { @@ -64,9 +66,20 @@ describe("TraceRoute", () => { ); expect(screen.getByText("Route back:")).toBeInTheDocument(); + + expect(screen.getAllByText("Source Node")).toHaveLength(2); + + expect(screen.getAllByText("Destination Node")).toHaveLength(2); + expect(screen.getByText("Node C")).toBeInTheDocument(); + expect(screen.getByText("Node A")).toBeInTheDocument(); + expect(screen.getByText("↓ 35dB")).toBeInTheDocument(); expect(screen.getByText("↓ 45dB")).toBeInTheDocument(); + + expect(screen.getByText("↓ 15dB")).toBeInTheDocument(); + expect(screen.getByText("↓ 25dB")).toBeInTheDocument(); + }); it("renders '??' for missing SNR values", () => { @@ -78,18 +91,22 @@ describe("TraceRoute", () => { /> ); - expect(screen.getAllByText("↓ ??dB").length).toBeGreaterThan(0); + expect(screen.getByText("Node A")).toBeInTheDocument(); + expect(screen.getAllByText("↓ ??dB")).toHaveLength(2); }); it("renders hop hex if node is not found", () => { render( ); - expect(screen.getByText(/^!63$/)).toBeInTheDocument(); // 99 in hex + expect(screen.getByText(/^!63$/)).toBeInTheDocument(); + expect(screen.getByText("↓ 5dB")).toBeInTheDocument(); + expect(screen.getByText("↓ 15dB")).toBeInTheDocument(); }); -}); +}); \ No newline at end of file diff --git a/src/components/PageComponents/Messages/TraceRoute.tsx b/src/components/PageComponents/Messages/TraceRoute.tsx index 624960ac..e67baacc 100644 --- a/src/components/PageComponents/Messages/TraceRoute.tsx +++ b/src/components/PageComponents/Messages/TraceRoute.tsx @@ -23,7 +23,7 @@ const RoutePath = ({ title, startNode, endNode, path, snr }: RoutePathProps) => const { getNode } = useDevice(); return ( - +

    {title}

    {startNode?.user?.longName}

    ↓ {snr?.[0] ?? "??"}dB

    diff --git a/src/components/PageLayout.tsx b/src/components/PageLayout.tsx index 40d6459f..ed1feab4 100644 --- a/src/components/PageLayout.tsx +++ b/src/components/PageLayout.tsx @@ -105,14 +105,14 @@ export const PageLayout = ({ > {children} - {/*
    */} +
    {/* Right Sidebar */} {rightBar && (