Merge branch 'master' into position-precision-fix

This commit is contained in:
Hunter Thornsberry
2025-03-16 01:34:57 -04:00
committed by GitHub
62 changed files with 2417 additions and 623 deletions
@@ -123,6 +123,7 @@ export const Channel = ({ channel }: SettingsPanelProps) => {
{
type: "passwordGenerator",
name: "settings.psk",
id: 'channel-psk',
label: "Pre-Shared Key",
description:
"Supported PSK lengths: 256-bit, 128-bit, 8-bit, Empty (0-bit)",
@@ -0,0 +1,129 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, 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', () => ({
useDevice: vi.fn()
}));
vi.mock('@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog', () => ({
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 as any).mockReturnValue({
config: {
device: mockDeviceConfig
},
setWorkingConfig: setWorkingConfigMock
});
// Mock the useUnsafeRolesDialog hook
validateRoleSelectionMock.mockResolvedValue(true);
(useUnsafeRolesDialog as any).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,11 +1,13 @@
import type { DeviceValidation } from "@app/validation/config/device.tsx";
import type { DeviceValidation } from "@app/validation/config/device.ts";
import { create } from "@bufbuild/protobuf";
import { DynamicForm } from "@components/Form/DynamicForm.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
import { Protobuf } from "@meshtastic/core";
import { useUnsafeRolesDialog } from "@components/Dialog/UnsafeRolesDialog/useUnsafeRolesDialog.ts";
export const Device = () => {
const { config, setWorkingConfig } = useDevice();
const { validateRoleSelection } = useUnsafeRolesDialog();
const onSubmit = (data: DeviceValidation) => {
setWorkingConfig(
@@ -14,10 +16,9 @@ export const Device = () => {
case: "device",
value: data,
},
}),
})
);
};
return (
<DynamicForm<DeviceValidation>
onSubmit={onSubmit}
@@ -32,23 +33,9 @@ export const Device = () => {
name: "role",
label: "Role",
description: "What role the device performs on the mesh",
validate: validateRoleSelection,
properties: {
enumValue: {
Client: Protobuf.Config.Config_DeviceConfig_Role.CLIENT,
"Client Mute":
Protobuf.Config.Config_DeviceConfig_Role.CLIENT_MUTE,
Router: Protobuf.Config.Config_DeviceConfig_Role.ROUTER,
Repeater: Protobuf.Config.Config_DeviceConfig_Role.REPEATER,
Tracker: Protobuf.Config.Config_DeviceConfig_Role.TRACKER,
Sensor: Protobuf.Config.Config_DeviceConfig_Role.SENSOR,
TAK: Protobuf.Config.Config_DeviceConfig_Role.TAK,
"Client Hidden":
Protobuf.Config.Config_DeviceConfig_Role.CLIENT_HIDDEN,
"Lost and Found":
Protobuf.Config.Config_DeviceConfig_Role.LOST_AND_FOUND,
"TAK Tracker":
Protobuf.Config.Config_DeviceConfig_Role.TAK_TRACKER,
},
enumValue: Protobuf.Config.Config_DeviceConfig_Role,
formatEnumName: true,
},
},
@@ -12,7 +12,7 @@ import { useCallback } from "react";
export const Position = () => {
const { config, setWorkingConfig } = useDevice();
const { flagsValue, activeFlags, toggleFlag, getAllFlags } = usePositionFlags(
config.position.positionFlags ?? 0,
config?.position.positionFlags ?? 0,
);
const onSubmit = (data: PositionValidation) => {
@@ -5,7 +5,7 @@ import { useAppStore } from "@core/stores/appStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { subscribeAll } from "@core/subscriptions.ts";
import { randId } from "@core/utils/randId.ts";
import { BleConnection, Constants } from "@meshtastic/js";
import { BleConnection, ServiceUuid } from "@meshtastic/js";
import { useCallback, useEffect, useState } from "react";
export const BLE = ({ closeDialog }: TabElementProps) => {
@@ -58,7 +58,7 @@ export const BLE = ({ closeDialog }: TabElementProps) => {
onClick={async () => {
await navigator.bluetooth
.requestDevice({
filters: [{ services: [Constants.ServiceUuid] }],
filters: [{ services: [ServiceUuid] }],
})
.then((device) => {
const exists = bleDevices.findIndex((d) => d.id === device.id);
@@ -1,8 +1,8 @@
import { describe, it, vi, expect } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { HTTP } from "@components/PageComponents/Connect/HTTP.tsx";
import { TransportHTTP } from "@meshtastic/transport-http";
import { MeshDevice } from "@meshtastic/core";
import { TransportHTTP } from "@meshtastic/transport-http";
import { vi, describe, it, expect } from "vitest";
vi.mock("@core/stores/appStore.ts", () => ({
useAppStore: vi.fn(() => ({ setSelectedDevice: vi.fn() })),
@@ -41,25 +41,27 @@ describe("HTTP Component", () => {
it("allows input field to be updated", () => {
render(<HTTP closeDialog={vi.fn()} />);
const input = screen.getByRole("textbox");
fireEvent.change(input, { target: { value: "meshtastic.local" } });
expect(input).toHaveValue("meshtastic.local");
const inputField = screen.getByRole("textbox");
fireEvent.change(inputField, { target: { value: 'meshtastic.local' } })
expect(screen.getByPlaceholderText("000.000.000.000 / meshtastic.local")).toBeInTheDocument();
});
it("toggles HTTPS switch and updates prefix", () => {
render(<HTTP closeDialog={vi.fn()} />);
const switchInput = screen.getByRole("switch");
expect(screen.getByText("http://")).toBeInTheDocument();
fireEvent.click(switchInput);
fireEvent.click(switchInput)
expect(screen.getByText("https://")).toBeInTheDocument();
fireEvent.click(switchInput);
fireEvent.click(switchInput)
expect(switchInput).not.toBeChecked();
expect(screen.getByText("http://")).toBeInTheDocument();
});
it("enables HTTPS toggle when location protocol is https", () => {
Object.defineProperty(globalThis, "location", {
Object.defineProperty(window, "location", {
value: { protocol: "https:" },
writable: true,
});
@@ -68,24 +70,27 @@ describe("HTTP Component", () => {
const switchInput = screen.getByRole("switch");
expect(switchInput).toBeChecked();
expect(screen.getByText("https://")).toBeInTheDocument();
});
it.skip("submits form and triggers connection process", () => {
// This will need further work to test, as it involves a lot of other plumbing mocking
it.skip("submits form and triggers connection process", async () => {
const closeDialog = vi.fn();
render(<HTTP closeDialog={closeDialog} />);
const button = screen.getByRole("button", { name: "Connect" });
expect(button).not.toBeDisabled();
fireEvent.click(button);
waitFor(() => {
expect(button).toBeDisabled();
expect(closeDialog).toHaveBeenCalled();
expect(TransportHTTP.create).toHaveBeenCalled();
expect(MeshDevice).toHaveBeenCalled();
});
try {
fireEvent.click(button);
await waitFor(() => {
expect(button).toBeDisabled();
expect(closeDialog).toBeCalled();
expect(TransportHTTP.create).toBeCalled();
expect(MeshDevice).toBeCalled();
});
} catch (e) {
console.error(e)
}
});
});
@@ -1,14 +1,10 @@
import { type MessageWithState, useDevice } from "@core/stores/deviceStore.ts";
import { Message } from "@components/PageComponents/Messages/Message.tsx";
import { MessageInput } from "@components/PageComponents/Messages/MessageInput.tsx";
import type { Types } from "@meshtastic/core";
import { InboxIcon } from "lucide-react";
import { useCallback, useEffect, useRef } from "react";
export interface ChannelChatProps {
messages?: MessageWithState[];
channel: Types.ChannelNumber;
to: Types.Destination;
}
const EmptyState = () => (
@@ -20,8 +16,6 @@ const EmptyState = () => (
export const ChannelChat = ({
messages,
channel,
to,
}: ChannelChatProps) => {
const { nodes } = useDevice();
const messagesEndRef = useRef<HTMLDivElement>(null);
@@ -30,10 +24,12 @@ export const ChannelChat = ({
const scrollToBottom = useCallback(() => {
const scrollContainer = scrollContainerRef.current;
if (scrollContainer) {
const isNearBottom = scrollContainer.scrollHeight -
scrollContainer.scrollTop -
scrollContainer.clientHeight <
const isNearBottom =
scrollContainer.scrollHeight -
scrollContainer.scrollTop -
scrollContainer.clientHeight <
100;
if (isNearBottom) {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}
@@ -42,7 +38,7 @@ export const ChannelChat = ({
useEffect(() => {
scrollToBottom();
}, [scrollToBottom]);
}, [scrollToBottom, messages]);
if (!messages?.length) {
return (
@@ -50,34 +46,31 @@ export const ChannelChat = ({
<div className="flex-1 flex items-center justify-center">
<EmptyState />
</div>
<div className="shrink-0 p-4 w-full dark:bg-slate-900">
<MessageInput to={to} channel={channel} maxBytes={200} />
</div>
</div>
);
}
return (
<div className="flex flex-col h-full container mx-auto">
<div className="flex-1 overflow-y-auto" ref={scrollContainerRef}>
<div className="w-full h-full flex flex-col justify-end pl-4 pr-44">
{messages.map((message, index) => {
return (
<Message
key={message.id}
message={message}
sender={nodes.get(message.from)}
lastMsgSameUser={index > 0 &&
messages[index - 1].from === message.from}
/>
);
})}
<div
ref={scrollContainerRef}
className="flex-1 overflow-y-auto pl-4 pr-4 md:pr-44"
>
<div className="flex flex-col justify-end min-h-full">
{messages.map((message, index) => (
<Message
key={message.id}
message={message}
sender={nodes.get(message.from)}
lastMsgSameUser={
index > 0 && messages[index - 1].from === message.from
}
/>
))}
<div ref={messagesEndRef} className="w-full" />
</div>
</div>
<div className="shrink-0 mt-2 p-4 w-full dark:bg-slate-900">
<MessageInput to={to} channel={channel} maxBytes={200} />
</div>
</div>
);
};
};
@@ -0,0 +1,152 @@
import { MessageInput } from '@components/PageComponents/Messages/MessageInput.tsx';
import { useDevice } from "@core/stores/deviceStore.ts";
import { vi, describe, it, expect, beforeEach, Mock } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
vi.mock("@core/stores/deviceStore.ts", () => ({
useDevice: vi.fn(),
}));
vi.mock("@core/utils/debounce.ts", () => ({
debounce: (fn: () => void) => fn,
}));
vi.mock("@components/UI/Button.tsx", () => ({
Button: ({ children, ...props }: { children: React.ReactNode }) => <button {...props}>{children}</button>
}));
vi.mock("@components/UI/Input.tsx", () => ({
Input: (props: any) => <input {...props} />
}));
vi.mock("lucide-react", () => ({
SendIcon: () => <div data-testid="send-icon">Send</div>
}));
// TODO: getting an error with this test
describe('MessageInput Component', () => {
const mockProps = {
to: "broadcast" as const,
channel: 0 as const,
maxBytes: 100,
};
const mockSetMessageDraft = vi.fn();
const mockSetMessageState = vi.fn();
const mockSendText = vi.fn().mockResolvedValue(123);
beforeEach(() => {
vi.clearAllMocks();
(useDevice as Mock).mockReturnValue({
connection: {
sendText: mockSendText,
},
setMessageState: mockSetMessageState,
messageDraft: "",
setMessageDraft: mockSetMessageDraft,
hardware: {
myNodeNum: 1234567890,
},
});
});
it('renders correctly with initial state', () => {
render(<MessageInput {...mockProps} />);
expect(screen.getByPlaceholderText('Enter Message')).toBeInTheDocument();
expect(screen.getByTestId('send-icon')).toBeInTheDocument();
expect(screen.getByText('0/100')).toBeInTheDocument();
});
it('updates local draft and byte count when typing', () => {
render(<MessageInput {...mockProps} />);
const inputField = screen.getByPlaceholderText('Enter Message');
fireEvent.change(inputField, { target: { value: 'Hello' } })
expect(screen.getByText('5/100')).toBeInTheDocument();
expect(inputField).toHaveValue('Hello');
expect(mockSetMessageDraft).toHaveBeenCalledWith('Hello');
});
it.skip('does not allow input exceeding max bytes', () => {
render(<MessageInput {...mockProps} maxBytes={5} />);
const inputField = screen.getByPlaceholderText('Enter Message');
expect(screen.getByText('0/100')).toBeInTheDocument();
userEvent.type(inputField, 'Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis p')
expect(screen.getByText('100/100')).toBeInTheDocument();
expect(inputField).toHaveValue('Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean m');
});
it.skip('sends message and resets form when submitting', async () => {
try {
render(<MessageInput {...mockProps} />);
const inputField = screen.getByPlaceholderText('Enter Message');
const submitButton = screen.getByText('Send');
fireEvent.change(inputField, { target: { value: 'Test Message' } });
fireEvent.click(submitButton);
const form = screen.getByRole('form');
fireEvent.submit(form);
expect(mockSendText).toHaveBeenCalledWith('Test message', 'broadcast', true, 0);
await waitFor(() => {
expect(mockSetMessageState).toHaveBeenCalledWith(
'broadcast',
0,
'broadcast',
1234567890,
123,
'ack'
);
});
expect(inputField).toHaveValue('');
expect(screen.getByText('0/100')).toBeInTheDocument();
expect(mockSetMessageDraft).toHaveBeenCalledWith('');
} catch (e) {
console.error(e);
}
});
it('prevents sending empty messages', () => {
render(<MessageInput {...mockProps} />);
const form = screen.getByPlaceholderText('Enter Message')
fireEvent.submit(form);
expect(mockSendText).not.toHaveBeenCalled();
});
it('initializes with existing message draft', () => {
(useDevice as Mock).mockReturnValue({
connection: {
sendText: mockSendText,
},
setMessageState: mockSetMessageState,
messageDraft: "Existing draft",
setMessageDraft: mockSetMessageDraft,
isQueueingMessages: false,
queueStatus: { free: 10 },
hardware: {
myNodeNum: 1234567890,
},
});
render(<MessageInput {...mockProps} />);
const inputField = screen.getByRole('textbox');
expect(inputField).toHaveValue('Existing draft');
});
});
@@ -1,4 +1,4 @@
import { debounce } from "../../../core/utils/debounce.ts";
import { debounce } from "@core/utils/debounce.ts";
import { Button } from "@components/UI/Button.tsx";
import { Input } from "@components/UI/Input.tsx";
import { useDevice } from "@core/stores/deviceStore.ts";
@@ -22,6 +22,8 @@ export const MessageInput = ({
setMessageState,
messageDraft,
setMessageDraft,
isQueueingMessages,
queueStatus,
hardware,
} = useDevice();
const myNodeNum = hardware.myNodeNum;
@@ -33,8 +35,10 @@ export const MessageInput = ({
[setMessageDraft],
);
// sends the message to the selected destination
const sendText = useCallback(
async (message: string) => {
await connection
?.sendText(message, to, true, channel)
.then((id: number) =>
@@ -58,7 +62,7 @@ export const MessageInput = ({
)
);
},
[channel, connection, myNodeNum, setMessageState, to],
[channel, connection, myNodeNum, setMessageState, to, queueStatus],
);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -81,15 +85,18 @@ export const MessageInput = ({
if (localDraft === "") return;
const message = formData.get("messageInput") as string;
startTransition(() => {
sendText(message);
setLocalDraft("");
setMessageDraft("");
setMessageBytes(0);
if (!isQueueingMessages) {
sendText(message);
setLocalDraft("");
setMessageDraft("");
setMessageBytes(0);
}
});
}}
>
<div className="flex grow gap-2">
<span className="w-full">
<div className="flex grow gap-2 ">
<label className="w-full">
<Input
autoFocus
minLength={1}
@@ -98,12 +105,12 @@ export const MessageInput = ({
value={localDraft}
onChange={handleInputChange}
/>
</span>
<div className="flex items-center w-24 p-2 place-content-end">
</label>
<label data-testid="byte-counter" className="flex items-center w-24 p-2 place-content-end">
{messageBytes}/{maxBytes}
</div>
</label>
<Button type="submit">
<Button type="submit" className="dark:bg-white dark:text-slate-900 dark:hover:bg-slate-400 dark:hover:text-white">
<SendIcon size={16} />
</Button>
</div>