committed by
GitHub
60 changed files with 2917 additions and 1873 deletions
@ -1,2 +0,0 @@ |
|||
dist/build.tar |
|||
dist/output |
|||
@ -1,10 +0,0 @@ |
|||
FROM nginx:1.27.2-alpine |
|||
|
|||
RUN rm -r /usr/share/nginx/html \ |
|||
&& mkdir /usr/share/nginx/html |
|||
|
|||
WORKDIR /usr/share/nginx/html |
|||
|
|||
ADD dist . |
|||
|
|||
CMD nginx -g "daemon off;" |
|||
File diff suppressed because it is too large
@ -0,0 +1,2 @@ |
|||
../dist/build.tar |
|||
../dist/output |
|||
@ -0,0 +1,15 @@ |
|||
FROM nginx:1.27-alpine |
|||
|
|||
RUN rm -r /usr/share/nginx/html \ |
|||
&& mkdir -p /usr/share/nginx/html \ |
|||
&& mkdir -p /etc/nginx/conf.d |
|||
|
|||
WORKDIR /usr/share/nginx/html |
|||
|
|||
ADD dist . |
|||
|
|||
COPY ./infra/default.conf /etc/nginx/conf.d/default.conf |
|||
|
|||
EXPOSE 8080 |
|||
|
|||
CMD ["nginx", "-g", "daemon off;"] |
|||
@ -0,0 +1,42 @@ |
|||
server { |
|||
listen 8080; |
|||
server_name localhost; |
|||
|
|||
root /usr/share/nginx/html; |
|||
index index.html index.htm; |
|||
|
|||
location / { |
|||
try_files $uri $uri/ =404; |
|||
} |
|||
|
|||
error_page 500 502 503 504 /50x.html; |
|||
location = /50x.html { |
|||
internal; |
|||
} |
|||
|
|||
location ~ /\.ht { |
|||
deny all; |
|||
} |
|||
|
|||
gzip on; |
|||
gzip_disable "msie6"; |
|||
|
|||
gzip_vary on; |
|||
gzip_proxied any; |
|||
gzip_comp_level 6; |
|||
gzip_buffers 16 8k; |
|||
gzip_http_version 1.1; |
|||
gzip_types |
|||
text/plain |
|||
text/css |
|||
text/xml |
|||
text/javascript |
|||
application/javascript |
|||
application/x-javascript |
|||
application/json |
|||
application/xml |
|||
application/xml+rss |
|||
font/ttf |
|||
font/otf |
|||
image/svg+xml; |
|||
} |
|||
@ -1,6 +1,6 @@ |
|||
{ |
|||
"name": "meshtastic-web", |
|||
"version": "2.3.3-0", |
|||
"version": "2.6.0-0", |
|||
"type": "module", |
|||
"description": "Meshtastic web client", |
|||
"license": "GPL-3.0-only", |
|||
@ -34,11 +34,12 @@ |
|||
}, |
|||
"homepage": "https://meshtastic.org", |
|||
"dependencies": { |
|||
"@bufbuild/protobuf": "^2.2.3", |
|||
"@meshtastic/core": "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", |
|||
"@bufbuild/protobuf": "^2.2.5", |
|||
"@noble/curves": "^1.8.1", |
|||
"@radix-ui/react-accordion": "^1.2.3", |
|||
"@radix-ui/react-checkbox": "^1.1.4", |
|||
@ -59,49 +60,51 @@ |
|||
"class-validator": "^0.14.1", |
|||
"class-variance-authority": "^0.7.1", |
|||
"clsx": "^2.1.1", |
|||
"cmdk": "^1.0.4", |
|||
"cmdk": "^1.1.1", |
|||
"crypto-random-string": "^5.0.0", |
|||
"idb-keyval": "^6.2.1", |
|||
"immer": "^10.1.1", |
|||
"js-cookie": "^3.0.5", |
|||
"lucide-react": "^0.477.0", |
|||
"maplibre-gl": "5.1.1", |
|||
"react": "^19.0.0", |
|||
"react-dom": "^19.0.0", |
|||
"lucide-react": "^0.486.0", |
|||
"maplibre-gl": "5.3.0", |
|||
"react": "^19.1.0", |
|||
"react-dom": "^19.1.0", |
|||
"react-error-boundary": "^5.0.0", |
|||
"react-hook-form": "^7.54.2", |
|||
"react-map-gl": "8.0.1", |
|||
"react-hook-form": "^7.55.0", |
|||
"react-map-gl": "8.0.2", |
|||
"react-qrcode-logo": "^3.0.0", |
|||
"rfc4648": "^1.5.4", |
|||
"vite-plugin-node-polyfills": "^0.23.0", |
|||
"zod": "^3.24.2", |
|||
"zustand": "5.0.3" |
|||
}, |
|||
"devDependencies": { |
|||
"@tailwindcss/postcss": "^4.0.9", |
|||
"@tailwindcss/postcss": "^4.1.0", |
|||
"@testing-library/jest-dom": "^6.6.3", |
|||
"@testing-library/react": "^16.2.0", |
|||
"@testing-library/user-event": "^14.6.1", |
|||
"@types/chrome": "^0.0.307", |
|||
"@types/chrome": "^0.0.313", |
|||
"@types/js-cookie": "^3.0.6", |
|||
"@types/node": "^22.13.7", |
|||
"@types/react": "^19.0.10", |
|||
"@types/node": "^22.13.17", |
|||
"@types/react": "^19.0.12", |
|||
"@types/react-dom": "^19.0.4", |
|||
"@types/serviceworker": "^0.0.123", |
|||
"@types/serviceworker": "^0.0.127", |
|||
"@types/w3c-web-serial": "^1.0.8", |
|||
"@types/web-bluetooth": "^0.0.21", |
|||
"@vitejs/plugin-react": "^4.3.4", |
|||
"autoprefixer": "^10.4.20", |
|||
"gzipper": "^8.2.0", |
|||
"happy-dom": "^17.2.2", |
|||
"autoprefixer": "^10.4.21", |
|||
"gzipper": "^8.2.1", |
|||
"happy-dom": "^17.4.4", |
|||
"postcss": "^8.5.3", |
|||
"simple-git-hooks": "^2.11.1", |
|||
"tailwind-merge": "^3.0.2", |
|||
"tailwindcss": "^4.0.9", |
|||
"simple-git-hooks": "^2.12.1", |
|||
"tailwind-merge": "^3.1.0", |
|||
"tailwindcss": "^4.1.0", |
|||
"tailwindcss-animate": "^1.0.7", |
|||
"tar": "^7.4.3", |
|||
"testing-library": "^0.0.2", |
|||
"typescript": "^5.8.2", |
|||
"vite": "^6.2.0", |
|||
"vitest": "^3.0.7", |
|||
"vite-plugin-pwa": "^0.21.1" |
|||
"vite": "^6.2.4", |
|||
"vitest": "^3.1.1", |
|||
"vite-plugin-pwa": "^1.0.0" |
|||
} |
|||
} |
|||
|
|||
@ -0,0 +1,53 @@ |
|||
import { render, screen, fireEvent } from '@testing-library/react'; |
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'; |
|||
import { useMessageStore } from "@core/stores/messageStore.ts"; |
|||
import { ClearMessagesDialog } from "@components/Dialog/ClearMessagesDialog/ClearMessagesDialog.tsx"; |
|||
|
|||
vi.mock('@core/stores/messageStore.ts', () => ({ |
|||
useMessageStore: vi.fn(() => ({ |
|||
clearAllMessages: vi.fn(), |
|||
})), |
|||
})); |
|||
|
|||
describe('ClearMessagesDialog', () => { |
|||
const mockOnOpenChange = vi.fn(); |
|||
const mockClearAllMessages = vi.fn(); |
|||
|
|||
beforeEach(() => { |
|||
vi.mocked(useMessageStore).mockReturnValue({ clearAllMessages: mockClearAllMessages }); |
|||
mockOnOpenChange.mockClear(); |
|||
mockClearAllMessages.mockClear(); |
|||
}); |
|||
|
|||
it('renders the dialog when open is true', () => { |
|||
render(<ClearMessagesDialog open={true} onOpenChange={mockOnOpenChange} />); |
|||
expect(screen.getByText('Clear All Messages')).toBeVisible(); |
|||
expect(screen.getByText(/This action will clear all message history./)).toBeVisible(); |
|||
expect(screen.getByRole('button', { name: 'Dismiss' })).toBeVisible(); |
|||
expect(screen.getByRole('button', { name: 'Clear Messages' })).toBeVisible(); |
|||
}); |
|||
|
|||
it('does not render the dialog when open is false', () => { |
|||
render(<ClearMessagesDialog open={false} onOpenChange={mockOnOpenChange} />); |
|||
expect(screen.queryByText('Clear All Messages')).toBeNull(); |
|||
}); |
|||
|
|||
it('calls onOpenChange with false when the close button is clicked', () => { |
|||
render(<ClearMessagesDialog open={true} onOpenChange={mockOnOpenChange} />); |
|||
fireEvent.click(screen.getByRole('button', { name: 'Close' })); |
|||
expect(mockOnOpenChange).toHaveBeenCalledWith(false); |
|||
}); |
|||
|
|||
it('calls onOpenChange with false when the dismiss button is clicked', () => { |
|||
render(<ClearMessagesDialog open={true} onOpenChange={mockOnOpenChange} />); |
|||
fireEvent.click(screen.getByRole('button', { name: 'Dismiss' })); |
|||
expect(mockOnOpenChange).toHaveBeenCalledWith(false); |
|||
}); |
|||
|
|||
it('calls clearAllMessages and onOpenChange with false when the clear messages button is clicked', () => { |
|||
render(<ClearMessagesDialog open={true} onOpenChange={mockOnOpenChange} />); |
|||
fireEvent.click(screen.getByRole('button', { name: 'Clear Messages' })); |
|||
expect(mockClearAllMessages).toHaveBeenCalledTimes(1); |
|||
expect(mockOnOpenChange).toHaveBeenCalledWith(false); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,63 @@ |
|||
|
|||
import { Button } from "@components/UI/Button.tsx"; |
|||
import { |
|||
Dialog, |
|||
DialogClose, |
|||
DialogContent, |
|||
DialogDescription, |
|||
DialogFooter, |
|||
DialogHeader, |
|||
DialogTitle, |
|||
} from "@components/UI/Dialog.tsx"; |
|||
import { AlertTriangleIcon } from "lucide-react"; |
|||
import { useMessageStore } from "@core/stores/messageStore.ts"; |
|||
|
|||
export interface ClearMessagesDialogProps { |
|||
open: boolean; |
|||
onOpenChange: (open: boolean) => void; |
|||
} |
|||
|
|||
export const ClearMessagesDialog = ({ |
|||
open, |
|||
onOpenChange, |
|||
}: ClearMessagesDialogProps) => { |
|||
const { clearAllMessages } = useMessageStore(); |
|||
const handleCloseDialog = () => { |
|||
onOpenChange(false); |
|||
}; |
|||
|
|||
return ( |
|||
<Dialog open={open} onOpenChange={onOpenChange}> |
|||
<DialogContent> |
|||
<DialogClose onClick={handleCloseDialog} /> |
|||
<DialogHeader> |
|||
<DialogTitle className="flex items-center gap-2"> |
|||
<AlertTriangleIcon className="h-5 w-5 text-warning" /> |
|||
Clear All Messages |
|||
</DialogTitle> |
|||
<DialogDescription> |
|||
This action will clear all message history. This cannot be undone. |
|||
Are you sure you want to continue? |
|||
</DialogDescription> |
|||
</DialogHeader> |
|||
<DialogFooter className="mt-4"> |
|||
<Button |
|||
variant="outline" |
|||
onClick={handleCloseDialog} |
|||
> |
|||
Dismiss |
|||
</Button> |
|||
<Button |
|||
variant="destructive" |
|||
onClick={() => { |
|||
clearAllMessages(); |
|||
handleCloseDialog(); |
|||
}} |
|||
> |
|||
Clear Messages |
|||
</Button> |
|||
</DialogFooter> |
|||
</DialogContent> |
|||
</Dialog> |
|||
); |
|||
}; |
|||
@ -0,0 +1,114 @@ |
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; |
|||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'; |
|||
import { RebootOTADialog } from './RebootOTADialog.tsx'; |
|||
import { ReactNode } from "react"; |
|||
|
|||
const rebootOtaMock = vi.fn(); |
|||
let mockConnection: { rebootOta: (delay: number) => void } | undefined = { |
|||
rebootOta: rebootOtaMock, |
|||
}; |
|||
|
|||
vi.mock('@core/stores/deviceStore.ts', () => ({ |
|||
useDevice: () => ({ |
|||
connection: mockConnection, |
|||
}), |
|||
})); |
|||
|
|||
vi.mock('@components/UI/Button.tsx', async () => { |
|||
const actual = await vi.importActual('@components/UI/Button.tsx'); |
|||
return { |
|||
...actual, |
|||
Button: (props: any) => <button {...props} />, |
|||
}; |
|||
}); |
|||
|
|||
vi.mock('@components/UI/Input.tsx', async () => { |
|||
const actual = await vi.importActual('@components/UI/Input.tsx'); |
|||
return { |
|||
...actual, |
|||
Input: (props: any) => <input {...props} />, |
|||
}; |
|||
}); |
|||
|
|||
vi.mock('@components/UI/Dialog.tsx', () => { |
|||
return { |
|||
Dialog: ({ children }: { children: ReactNode }) => <div>{children}</div>, |
|||
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>, |
|||
DialogHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>, |
|||
DialogTitle: ({ children }: { children: ReactNode }) => <h1>{children}</h1>, |
|||
DialogDescription: ({ children }: { children: ReactNode }) => <p>{children}</p>, |
|||
DialogClose: () => null, |
|||
}; |
|||
}); |
|||
|
|||
|
|||
describe('RebootOTADialog', () => { |
|||
beforeEach(() => { |
|||
vi.useFakeTimers(); |
|||
rebootOtaMock.mockClear(); |
|||
}); |
|||
|
|||
afterEach(() => { |
|||
vi.useRealTimers(); |
|||
}); |
|||
|
|||
it('renders dialog with default input value', () => { |
|||
render(<RebootOTADialog open={true} onOpenChange={() => { }} />); |
|||
expect(screen.getByPlaceholderText(/enter delay/i)).toHaveValue(5); |
|||
expect(screen.getByText(/schedule reboot/i)).toBeInTheDocument(); |
|||
expect(screen.getByText(/reboot to ota mode now/i)).toBeInTheDocument(); |
|||
}); |
|||
|
|||
it('schedules a reboot with delay and calls rebootOta', async () => { |
|||
const onOpenChangeMock = vi.fn(); |
|||
render(<RebootOTADialog open={true} onOpenChange={onOpenChangeMock} />); |
|||
|
|||
fireEvent.change(screen.getByPlaceholderText(/enter delay/i), { |
|||
target: { value: '3' }, |
|||
}); |
|||
|
|||
fireEvent.click(screen.getByText(/schedule reboot/i)); |
|||
|
|||
expect(screen.getByText(/reboot has been scheduled/i)).toBeInTheDocument(); |
|||
|
|||
vi.advanceTimersByTime(3000); |
|||
|
|||
await waitFor(() => { |
|||
expect(rebootOtaMock).toHaveBeenCalledWith(0); |
|||
expect(onOpenChangeMock).toHaveBeenCalledWith(false); |
|||
}); |
|||
}); |
|||
|
|||
it('triggers an instant reboot', async () => { |
|||
const onOpenChangeMock = vi.fn(); |
|||
render(<RebootOTADialog open={true} onOpenChange={onOpenChangeMock} />); |
|||
|
|||
fireEvent.click(screen.getByText(/reboot to ota mode now/i)); |
|||
|
|||
await waitFor(() => { |
|||
expect(rebootOtaMock).toHaveBeenCalledWith(5); |
|||
expect(onOpenChangeMock).toHaveBeenCalledWith(false); |
|||
}); |
|||
}); |
|||
|
|||
it('does not call reboot if connection is undefined', async () => { |
|||
const onOpenChangeMock = vi.fn(); |
|||
|
|||
// simulate no connection
|
|||
mockConnection = undefined; |
|||
|
|||
render(<RebootOTADialog open={true} onOpenChange={onOpenChangeMock} />); |
|||
|
|||
fireEvent.click(screen.getByText(/schedule reboot/i)); |
|||
vi.advanceTimersByTime(5000); |
|||
|
|||
await waitFor(() => { |
|||
expect(rebootOtaMock).not.toHaveBeenCalled(); |
|||
expect(onOpenChangeMock).not.toHaveBeenCalled(); |
|||
}); |
|||
|
|||
// reset connection for other tests
|
|||
mockConnection = { rebootOta: rebootOtaMock }; |
|||
}); |
|||
|
|||
}); |
|||
@ -0,0 +1,104 @@ |
|||
import { useState } from "react"; |
|||
import { ClockIcon, RefreshCwIcon } from "lucide-react"; |
|||
import { Button } from "@components/UI/Button.tsx"; |
|||
import { |
|||
Dialog, |
|||
DialogClose, |
|||
DialogContent, |
|||
DialogDescription, |
|||
DialogHeader, |
|||
DialogTitle, |
|||
} from "@components/UI/Dialog.tsx"; |
|||
import { Input } from "@components/UI/Input.tsx"; |
|||
import { useDevice } from "@core/stores/deviceStore.ts"; |
|||
|
|||
export interface RebootOTADialogProps { |
|||
open: boolean; |
|||
onOpenChange: (open: boolean) => void; |
|||
} |
|||
|
|||
const DEFAULT_REBOOT_DELAY = 5; // seconds
|
|||
|
|||
export const RebootOTADialog = ({ open, onOpenChange }: RebootOTADialogProps) => { |
|||
const { connection } = useDevice(); |
|||
const [time, setTime] = useState<number>(DEFAULT_REBOOT_DELAY); |
|||
const [isScheduled, setIsScheduled] = useState(false); |
|||
const [inputValue, setInputValue] = useState(DEFAULT_REBOOT_DELAY.toString()); |
|||
|
|||
const handleSetTime = (e: React.ChangeEvent<HTMLInputElement>) => { |
|||
if (!e.target.validity.valid) { |
|||
e.preventDefault(); |
|||
return |
|||
}; |
|||
|
|||
const val = e.target.value; |
|||
setInputValue(val); |
|||
|
|||
const parsed = Number(val); |
|||
if (!isNaN(parsed) && parsed > 0) { |
|||
setTime(parsed); |
|||
} |
|||
}; |
|||
|
|||
const handleRebootWithTimeout = async () => { |
|||
if (!connection) return; |
|||
setIsScheduled(true); |
|||
|
|||
const delay = time > 0 ? time : DEFAULT_REBOOT_DELAY; |
|||
|
|||
await new Promise<void>((resolve) => { |
|||
setTimeout(() => { |
|||
console.log("Rebooting..."); |
|||
resolve(); |
|||
}, delay * 1000); |
|||
}).finally(() => { |
|||
setIsScheduled(false); |
|||
onOpenChange(false); |
|||
setInputValue(DEFAULT_REBOOT_DELAY.toString()); |
|||
}); |
|||
connection.rebootOta(0); |
|||
}; |
|||
|
|||
const handleInstantReboot = async () => { |
|||
if (!connection) return; |
|||
|
|||
await connection.rebootOta(DEFAULT_REBOOT_DELAY); |
|||
onOpenChange(false); |
|||
}; |
|||
|
|||
return ( |
|||
<Dialog open={open} onOpenChange={onOpenChange}> |
|||
<DialogContent> |
|||
<DialogClose /> |
|||
<DialogHeader> |
|||
<DialogTitle>Reboot to OTA Mode</DialogTitle> |
|||
<DialogDescription> |
|||
Reboot the connected node after a delay into OTA (Over-the-Air) mode. |
|||
</DialogDescription> |
|||
</DialogHeader> |
|||
|
|||
<div className="flex gap-2 p-2 items-center relative"> |
|||
<Input |
|||
type="number" |
|||
min={1} |
|||
max={86400} |
|||
className="dark:text-slate-900 appearance-none" |
|||
value={inputValue} |
|||
onChange={handleSetTime} |
|||
placeholder="Enter delay (sec)" |
|||
/> |
|||
<Button onClick={() => handleRebootWithTimeout()} className="w-9/12"> |
|||
<ClockIcon className="mr-2" size={18} /> |
|||
{isScheduled ? 'Reboot has been scheduled' : 'Schedule Reboot'} |
|||
</Button> |
|||
</div> |
|||
|
|||
<Button variant="destructive" onClick={() => handleInstantReboot()}> |
|||
<RefreshCwIcon className="mr-2" size={16} /> |
|||
Reboot to OTA Mode Now |
|||
</Button> |
|||
</DialogContent> |
|||
</Dialog> |
|||
); |
|||
}; |
|||
|
|||
@ -0,0 +1,283 @@ |
|||
// import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|||
// import { render, screen, fireEvent, 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', () => ({
|
|||
// DynamicForm: vi.fn(({ onSubmit }) => {
|
|||
// return (
|
|||
// <div data-testid="dynamic-form">
|
|||
// <select
|
|||
// data-testid="role-select"
|
|||
// onChange={(e) => {
|
|||
// 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('Network component', () => {
|
|||
// const setWorkingConfigMock = vi.fn();
|
|||
// const mockDeviceConfig = {
|
|||
// role: "CLIENT",
|
|||
// buttonGpio: 0,
|
|||
// buzzerGpio: 0,
|
|||
// rebroadcastMode: "ALL",
|
|||
// nodeInfoBroadcastSecs: 300,
|
|||
// doubleTapAsButtonPress: false,
|
|||
// disableTripleClick: false,
|
|||
// ledHeartbeatDisabled: false,
|
|||
// };
|
|||
|
|||
// beforeEach(() => {
|
|||
// vi.resetAllMocks();
|
|||
|
|||
// (useDevice as any).mockReturnValue({
|
|||
// config: {
|
|||
// device: mockDeviceConfig
|
|||
// },
|
|||
// setWorkingConfig: setWorkingConfigMock
|
|||
// });
|
|||
|
|||
// });
|
|||
|
|||
// afterEach(() => {
|
|||
// vi.clearAllMocks();
|
|||
// });
|
|||
|
|||
// it('should render the Network form', () => {
|
|||
// render(<Network />);
|
|||
// expect(screen.getByTestId('dynamic-form')).toBeInTheDocument();
|
|||
// });
|
|||
|
|||
// it('should call setWorkingConfig when form is submitted', async () => {
|
|||
// render(<Network />);
|
|||
|
|||
// 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(<Network />);
|
|||
|
|||
// // Simulate form submission
|
|||
// fireEvent.click(screen.getByTestId('submit-button'));
|
|||
|
|||
// await waitFor(() => {
|
|||
// expect(setWorkingConfigMock).toHaveBeenCalledWith(
|
|||
// expect.objectContaining({
|
|||
// payloadVariant: {
|
|||
// case: "network",
|
|||
// value: expect.any(Object)
|
|||
// }
|
|||
// })
|
|||
// );
|
|||
// });
|
|||
// });
|
|||
// });
|
|||
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; |
|||
import { render, screen, fireEvent, 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 }: any) => { |
|||
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 as any).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"); |
|||
screen.debug() |
|||
|
|||
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,175 +0,0 @@ |
|||
import { memo, useMemo } from "react"; |
|||
import { |
|||
Tooltip, |
|||
TooltipArrow, |
|||
TooltipContent, |
|||
TooltipProvider, |
|||
TooltipTrigger, |
|||
} from "@components/UI/Tooltip.tsx"; |
|||
import { |
|||
type MessageWithState, |
|||
useDeviceStore, |
|||
} from "@core/stores/deviceStore.ts"; |
|||
import { cn } from "@core/utils/cn.ts"; |
|||
import { Avatar } from "@components/UI/Avatar.tsx"; |
|||
import type { Protobuf } from "@meshtastic/core"; |
|||
import { AlertCircle, CheckCircle2, CircleEllipsis, LucideIcon } from "lucide-react"; |
|||
|
|||
type MessageStateValue = { |
|||
state: string; |
|||
icon: LucideIcon; |
|||
displayText: string; |
|||
} |
|||
|
|||
type MessageState = MessageWithState["state"]; |
|||
|
|||
interface MessageProps { |
|||
lastMsgSameUser: boolean; |
|||
message: MessageWithState; |
|||
sender: Protobuf.Mesh.NodeInfo; |
|||
} |
|||
|
|||
interface StatusTooltipProps { |
|||
state: MessageState; |
|||
children: React.ReactNode; |
|||
} |
|||
|
|||
interface StatusIconProps { |
|||
state: MessageState; |
|||
className?: string; |
|||
} |
|||
|
|||
const MESSAGE_STATES: Record<string, MessageStateValue> = { |
|||
ACK: { state: 'ack', icon: CheckCircle2, displayText: "Message delivered" }, |
|||
WAITING: { state: 'waiting', icon: CircleEllipsis, displayText: "Waiting for delivery" }, |
|||
FAILED: { state: 'failed', icon: AlertCircle, displayText: "Delivery failed" }, |
|||
}; |
|||
|
|||
const getMessageState = (state: MessageState): MessageStateValue => { |
|||
switch (state) { |
|||
case MESSAGE_STATES.ACK.state: |
|||
return MESSAGE_STATES.ACK; |
|||
case MESSAGE_STATES.WAITING.state: |
|||
return MESSAGE_STATES.WAITING; |
|||
case MESSAGE_STATES.FAILED.state: |
|||
return MESSAGE_STATES.FAILED; |
|||
default: |
|||
return MESSAGE_STATES.FAILED; |
|||
} |
|||
} |
|||
|
|||
const StatusTooltip = ({ state, children }: StatusTooltipProps) => ( |
|||
<TooltipProvider> |
|||
<Tooltip> |
|||
<TooltipTrigger asChild>{children}</TooltipTrigger> |
|||
<TooltipContent |
|||
className="rounded-md bg-slate-800 px-3 py-1.5 text-sm text-white dark:text-white shadow-md animate-in fade-in-0 zoom-in-95" |
|||
side="top" |
|||
align="center" |
|||
sideOffset={5} |
|||
> |
|||
{getMessageState(state).displayText ?? "An unknown error occurred"}; |
|||
<TooltipArrow className="fill-slate-800" /> |
|||
</TooltipContent> |
|||
</Tooltip> |
|||
</TooltipProvider> |
|||
); |
|||
|
|||
const StatusIcon = ({ state, className, ...otherProps }: StatusIconProps) => { |
|||
const msgState = getMessageState(state); |
|||
|
|||
const isFailed = msgState.state === 'failed' |
|||
|
|||
const iconClass = cn( |
|||
className, |
|||
"text-slate-500 dark:text-slate-400 size-5 shrink-0" |
|||
); |
|||
|
|||
const Icon = msgState.icon; |
|||
|
|||
return ( |
|||
<StatusTooltip state={state}> |
|||
<Icon |
|||
className={iconClass} |
|||
{...otherProps} |
|||
color={isFailed ? "red" : "currentColor"} |
|||
/> |
|||
</StatusTooltip> |
|||
); |
|||
}; |
|||
|
|||
const TimeDisplay = memo(({ date, className }: { date: Date; className?: string }) => ( |
|||
<div className={cn("flex items-center gap-2 shrink-0", className)}> |
|||
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono"> |
|||
{date.toLocaleDateString()} |
|||
</span> |
|||
<span className="text-xs text-slate-500 dark:text-slate-400 font-mono"> |
|||
{date.toLocaleTimeString(undefined, { |
|||
hour: "2-digit", |
|||
minute: "2-digit", |
|||
})} |
|||
</span> |
|||
</div> |
|||
)); |
|||
|
|||
export const Message = memo(({ lastMsgSameUser, message, sender }: MessageProps) => { |
|||
const { getDevices } = useDeviceStore(); |
|||
|
|||
const isDeviceUser = useMemo( |
|||
() => |
|||
getDevices() |
|||
.map((device) => device.nodes.get(device.hardware.myNodeNum)?.num) |
|||
.includes(message.from), |
|||
[getDevices, message.from] |
|||
); |
|||
|
|||
const messageUser = sender?.user; |
|||
|
|||
const getMessageTextStyles = (state: MessageState) => { |
|||
const msgState = getMessageState(state); |
|||
const isAcknowledged = msgState.state === 'ack' |
|||
const isFailed = msgState.state === 'failed' |
|||
|
|||
return cn( |
|||
"break-words overflow-hidden", |
|||
isAcknowledged |
|||
? "text-slate-900 dark:text-white" |
|||
: "text-slate-900 dark:text-slate-400", |
|||
isFailed && "text-red-500 dark:text-red-500", |
|||
); |
|||
}; |
|||
|
|||
const messageTextClass = useMemo(() => getMessageTextStyles(message.state), [message.state]); |
|||
|
|||
|
|||
return ( |
|||
<div className="flex flex-col w-full px-4 justify-start"> |
|||
<div |
|||
className={cn( |
|||
"flex flex-col flex-wrap items-start py-1", |
|||
isDeviceUser && "items-end" |
|||
)} |
|||
> |
|||
<div className="flex items-center gap-2 mb-2"> |
|||
{!lastMsgSameUser && ( |
|||
<div className="flex place-items-center gap-2 mb-1"> |
|||
<Avatar text={messageUser?.shortName ?? "UNK"} /> |
|||
<div className="flex flex-col"> |
|||
<span className="font-medium text-slate-900 dark:text-white truncate"> |
|||
{messageUser?.longName} |
|||
</span> |
|||
</div> |
|||
</div> |
|||
)} |
|||
</div> |
|||
<TimeDisplay date={message.rxTime} /> |
|||
<div className="flex place-items-center gap-2 pb-2"> |
|||
<div className={cn(isDeviceUser && "pl-11", messageTextClass)}> |
|||
{message.data} |
|||
</div> |
|||
<StatusIcon state={message.state} /> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
); |
|||
}); |
|||
@ -1,152 +1,154 @@ |
|||
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(), |
|||
import { vi, describe, it, expect, beforeEach } from 'vitest'; |
|||
import { MessageInput } from './MessageInput.tsx'; |
|||
import { useDevice } from '@core/stores/deviceStore.ts'; |
|||
import { useMessageStore } from '@core/stores/messageStore.ts'; |
|||
import { debounce } from '@core/utils/debounce.ts'; |
|||
import { Types } from "@meshtastic/core"; |
|||
|
|||
vi.mock('@components/UI/Button.tsx', () => ({ |
|||
Button: vi.fn(({ type, className, children, onClick, onSubmit }) => ( |
|||
<button type={type} className={className} onClick={onClick} onSubmit={onSubmit}> |
|||
{children} |
|||
</button> |
|||
)), |
|||
})); |
|||
|
|||
vi.mock("@core/utils/debounce.ts", () => ({ |
|||
debounce: (fn: () => void) => fn, |
|||
vi.mock('@components/UI/Input.tsx', () => ({ |
|||
Input: vi.fn(({ autoFocus, minLength, name, placeholder, value, onChange }) => ( |
|||
<input |
|||
autoFocus={autoFocus} |
|||
minLength={minLength} |
|||
name={name} |
|||
placeholder={placeholder} |
|||
value={value} |
|||
onChange={onChange} |
|||
/> |
|||
)), |
|||
})); |
|||
|
|||
vi.mock("@components/UI/Button.tsx", () => ({ |
|||
Button: ({ children, ...props }: { children: React.ReactNode }) => <button {...props}>{children}</button> |
|||
vi.mock('@core/stores/deviceStore.ts', () => ({ |
|||
useDevice: vi.fn(), |
|||
})); |
|||
|
|||
vi.mock("@components/UI/Input.tsx", () => ({ |
|||
Input: (props: any) => <input {...props} /> |
|||
vi.mock('@core/stores/messageStore.ts', () => ({ |
|||
useMessageStore: vi.fn(), |
|||
MessageState: { |
|||
Ack: 'ack', |
|||
Waiting: 'waiting', |
|||
Failed: 'failed', |
|||
}, |
|||
MessageType: { |
|||
Direct: 'direct', |
|||
Broadcast: 'broadcast', |
|||
}, |
|||
})); |
|||
|
|||
vi.mock("lucide-react", () => ({ |
|||
SendIcon: () => <div data-testid="send-icon">Send</div> |
|||
vi.mock('@core/utils/debounce.ts', () => ({ |
|||
debounce: vi.fn((fn) => fn), |
|||
})); |
|||
|
|||
// TODO: getting an error with this test
|
|||
describe('MessageInput Component', () => { |
|||
const mockProps = { |
|||
to: "broadcast" as const, |
|||
channel: 0 as const, |
|||
maxBytes: 100, |
|||
}; |
|||
vi.mock('lucide-react', () => ({ |
|||
SendIcon: vi.fn(() => <svg data-testid="send-icon" />), |
|||
})); |
|||
|
|||
const mockSetMessageDraft = vi.fn(); |
|||
describe('MessageInput', () => { |
|||
const mockSetMessageState = vi.fn(); |
|||
const mockSendText = vi.fn().mockResolvedValue(123); |
|||
const mockSetActiveChat = vi.fn(); |
|||
const mockSetDraft = vi.fn(); |
|||
const mockGetDraft = vi.fn(); |
|||
const mockClearDraft = vi.fn(); |
|||
const mockSendText = vi.fn(); |
|||
|
|||
beforeEach(() => { |
|||
vi.clearAllMocks(); |
|||
|
|||
(useDevice as Mock).mockReturnValue({ |
|||
(useDevice as ReturnType<typeof vi.fn>).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') |
|||
(useMessageStore as unknown as ReturnType<typeof vi.fn>).mockReturnValue({ |
|||
setMessageState: mockSetMessageState, |
|||
activeChat: 123, |
|||
setDraft: mockSetDraft, |
|||
getDraft: mockGetDraft, |
|||
clearDraft: mockClearDraft, |
|||
}); |
|||
|
|||
expect(screen.getByText('100/100')).toBeInTheDocument(); |
|||
expect(inputField).toHaveValue('Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean m'); |
|||
mockSetMessageState.mockClear(); |
|||
mockSetActiveChat.mockClear(); |
|||
mockSetDraft.mockClear(); |
|||
mockGetDraft.mockClear(); |
|||
mockClearDraft.mockClear(); |
|||
mockSendText.mockClear(); |
|||
(debounce as ReturnType<typeof vi.fn>).mockImplementation((fn) => fn); |
|||
}); |
|||
|
|||
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' |
|||
); |
|||
const renderComponent = (props: { to: Types.Destination; channel: Types.ChannelNumber; maxBytes: number }) => { |
|||
render(<MessageInput {...props} />); |
|||
}; |
|||
|
|||
it.skip('sends text message and updates state to Ack on submit', async () => { |
|||
renderComponent({ to: 2, channel: 3, maxBytes: 256 }); |
|||
const inputElement = screen.getByPlaceholderText('Enter Message') as HTMLInputElement; |
|||
fireEvent.change(inputElement, { target: { value: 'Hello' } }); |
|||
const formElement = screen.getByRole('form'); |
|||
fireEvent.submit(formElement); |
|||
|
|||
await waitFor(() => { |
|||
expect(mockSendText).toHaveBeenCalledWith('Hello', 2, true, 3); |
|||
expect(mockSetMessageState).toHaveBeenCalledWith({ |
|||
type: 'direct', |
|||
key: 123, |
|||
messageId: undefined, |
|||
newState: 'ack', |
|||
}); |
|||
|
|||
expect(inputField).toHaveValue(''); |
|||
expect(screen.getByText('0/100')).toBeInTheDocument(); |
|||
expect(mockSetMessageDraft).toHaveBeenCalledWith(''); |
|||
} catch (e) { |
|||
console.error(e); |
|||
} |
|||
expect(mockClearDraft).toHaveBeenCalledWith(2); |
|||
expect(inputElement.value).toBe(''); |
|||
expect(screen.getByTestId('byte-counter')).toHaveTextContent('0/256'); |
|||
}); |
|||
}); |
|||
it('prevents sending empty messages', () => { |
|||
render(<MessageInput {...mockProps} />); |
|||
|
|||
const form = screen.getByPlaceholderText('Enter Message') |
|||
fireEvent.submit(form); |
|||
|
|||
expect(mockSendText).not.toHaveBeenCalled(); |
|||
it.skip('sends broadcast message if to is "broadcast" and updates state to Ack', async () => { |
|||
renderComponent({ to: 'broadcast', channel: 5, maxBytes: 256 }); |
|||
const inputElement = screen.getByPlaceholderText('Enter Message') as HTMLInputElement; |
|||
fireEvent.change(inputElement, { target: { value: 'Broadcast message' } }); |
|||
const formElement = screen.getByRole('form'); |
|||
fireEvent.submit(formElement); |
|||
|
|||
await waitFor(() => { |
|||
expect(mockSendText).toHaveBeenCalledWith('Broadcast message', 'broadcast', true, 5); |
|||
expect(mockSetMessageState).toHaveBeenCalledWith({ |
|||
type: 'broadcast', |
|||
key: 123, |
|||
messageId: undefined, |
|||
newState: 'ack', |
|||
}); |
|||
expect(mockClearDraft).toHaveBeenCalledWith('broadcast'); |
|||
expect(inputElement.value).toBe(''); |
|||
expect(screen.getByTestId('byte-counter')).toHaveTextContent('0/256'); |
|||
}); |
|||
}); |
|||
|
|||
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, |
|||
}, |
|||
it('updates state to Failed if sendText throws an error', async () => { |
|||
mockSendText.mockRejectedValue({ id: 456 }); |
|||
renderComponent({ to: 3, channel: 1, maxBytes: 256 }); |
|||
const inputElement = screen.getByPlaceholderText('Enter Message') as HTMLInputElement; |
|||
fireEvent.change(inputElement, { target: { value: 'Error message' } }); |
|||
const formElement = screen.getByRole('form'); |
|||
fireEvent.submit(formElement); |
|||
|
|||
await waitFor(() => { |
|||
expect(mockSendText).toHaveBeenCalledWith('Error message', 3, true, 1); |
|||
expect(mockSetMessageState).toHaveBeenCalledWith({ |
|||
type: 'direct', |
|||
key: 123, |
|||
messageId: 456, |
|||
newState: 'failed', |
|||
}); |
|||
expect(mockClearDraft).toHaveBeenCalledWith(3); |
|||
expect(inputElement.value).toBe(''); |
|||
expect(screen.getByTestId('byte-counter')).toHaveTextContent('0/256'); |
|||
}); |
|||
|
|||
render(<MessageInput {...mockProps} />); |
|||
|
|||
const inputField = screen.getByRole('textbox'); |
|||
|
|||
expect(inputField).toHaveValue('Existing draft'); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,51 @@ |
|||
import type { Types } from "@meshtastic/js"; |
|||
import { Message, MessageType, MessageState } from "@core/stores/messageStore.ts"; |
|||
|
|||
class PacketToMessageDTO { |
|||
channel: Types.ChannelNumber; |
|||
to: number; |
|||
from: number; |
|||
date: number; // (timestamp ms)
|
|||
messageId: number; |
|||
state: MessageState; |
|||
message: string; |
|||
type: MessageType; |
|||
|
|||
constructor(data: Types.PacketMetadata<string>, nodeNum: number) { |
|||
this.channel = data.channel; |
|||
this.to = data.to; |
|||
this.from = data.from; |
|||
this.messageId = data.id; |
|||
this.state = data.from !== nodeNum ? MessageState.Ack : MessageState.Waiting; |
|||
this.message = data.data; |
|||
this.type = (data.type === 'direct') ? MessageType.Direct : MessageType.Broadcast; |
|||
|
|||
let dateTimestamp = Date.now(); |
|||
if (data.rxTime instanceof Date) { |
|||
const timeValue = data.rxTime.getTime(); |
|||
|
|||
if (!isNaN(timeValue)) { |
|||
dateTimestamp = timeValue; |
|||
} |
|||
} |
|||
else if (data.rxTime != null) { |
|||
console.warn(`Received rxTime in PacketToMessageDTO was not a Date object as expected (type: ${typeof data.rxTime}, value: ${data.rxTime}). Using current time as fallback.`); |
|||
} |
|||
this.date = dateTimestamp; |
|||
} |
|||
|
|||
toMessage(): Message { |
|||
return { |
|||
channel: this.channel, |
|||
to: this.to, |
|||
from: this.from, |
|||
date: this.date, |
|||
messageId: this.messageId, |
|||
state: this.state, |
|||
message: this.message, |
|||
type: this.type, |
|||
}; |
|||
} |
|||
} |
|||
|
|||
export default PacketToMessageDTO; |
|||
@ -0,0 +1,52 @@ |
|||
import { renderHook, act } from '@testing-library/react' |
|||
import useLocalStorage from './useLocalStorage' |
|||
import { beforeEach, describe, expect, it } from "vitest"; |
|||
|
|||
describe('useLocalStorage', () => { |
|||
const key = 'test-key' |
|||
|
|||
beforeEach(() => { |
|||
localStorage.clear() |
|||
}) |
|||
|
|||
it('should initialize with initial value if localStorage is empty', () => { |
|||
const { result } = renderHook(() => useLocalStorage(key, 'initial')) |
|||
const [value] = result.current |
|||
expect(value).toBe('initial') |
|||
}) |
|||
|
|||
it('should read existing value from localStorage', () => { |
|||
localStorage.setItem(key, JSON.stringify('stored')) |
|||
const { result } = renderHook(() => useLocalStorage(key, 'initial')) |
|||
const [value] = result.current |
|||
expect(value).toBe('stored') |
|||
}) |
|||
|
|||
it('should update localStorage when setValue is called', () => { |
|||
const { result } = renderHook(() => useLocalStorage(key, 'initial')) |
|||
const [, setValue] = result.current |
|||
|
|||
act(() => { |
|||
setValue('updated') |
|||
}) |
|||
|
|||
expect(localStorage.getItem(key)).toBe(JSON.stringify('updated')) |
|||
expect(result.current[0]).toBe('updated') |
|||
}) |
|||
|
|||
it('should remove value from localStorage when removeValue is called', () => { |
|||
const { result } = renderHook(() => useLocalStorage(key, 'initial')) |
|||
const [, setValue, removeValue] = result.current |
|||
|
|||
act(() => { |
|||
setValue('to-be-removed') |
|||
}) |
|||
|
|||
act(() => { |
|||
removeValue() |
|||
}) |
|||
|
|||
expect(localStorage.getItem(key)).toBeNull() |
|||
expect(result.current[0]).toBe('initial') |
|||
}) |
|||
}) |
|||
@ -0,0 +1,65 @@ |
|||
import { renderHook, act } from "@testing-library/react"; |
|||
import { describe, it, expect, vi, beforeEach } from "vitest"; |
|||
import { usePinnedItems } from "./usePinnedItems.ts"; |
|||
|
|||
const mockSetPinnedItems = vi.fn(); |
|||
const mockUseLocalStorage = vi.fn(); |
|||
|
|||
vi.mock("@core/hooks/useLocalStorage.ts", () => ({ |
|||
default: (...args: any[]) => 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,19 @@ |
|||
import useLocalStorage from "@core/hooks/useLocalStorage.ts"; |
|||
import { useCallback } from "react"; |
|||
|
|||
export function usePinnedItems({ storageName }: { storageName: string }) { |
|||
const [pinnedItems, setPinnedItems] = useLocalStorage<string[]>(storageName, []); |
|||
|
|||
const togglePinnedItem = useCallback((label: string) => { |
|||
setPinnedItems((prev) => |
|||
prev.includes(label) |
|||
? prev.filter((g) => g !== label) |
|||
: [...prev, label] |
|||
); |
|||
}, []); |
|||
|
|||
return { |
|||
pinnedItems, |
|||
togglePinnedItem, |
|||
}; |
|||
} |
|||
@ -0,0 +1,81 @@ |
|||
import { renderHook, act } from '@testing-library/react' |
|||
import { useToast } from "@core/hooks/useToast.ts" |
|||
import { Button } from '@components/UI/Button.tsx' |
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; |
|||
|
|||
describe('useToast', () => { |
|||
beforeEach(() => { |
|||
// Reset toast memory state before each test
|
|||
// our hook uses global memory to store toasts
|
|||
// @ts-expect-error - internal test reset
|
|||
globalThis.memoryState = { toasts: [] } |
|||
vi.useFakeTimers() |
|||
}) |
|||
|
|||
afterEach(() => { |
|||
vi.useRealTimers() |
|||
}) |
|||
|
|||
it('should create a toast with title, description, and action', () => { |
|||
const { result } = renderHook(() => useToast()) |
|||
|
|||
act(() => { |
|||
result.current.toast({ |
|||
title: 'Backup Reminder', |
|||
description: 'Don\'t forget to backup!', |
|||
action: <Button>Backup Now</Button> |
|||
}) |
|||
vi.runAllTimers() |
|||
}) |
|||
|
|||
const toast = result.current.toasts[0] |
|||
expect(result.current.toasts.length).toBe(1) |
|||
expect(toast.title).toBe('Backup Reminder') |
|||
expect(toast.description).toBe('Don\'t forget to backup!') |
|||
expect(toast.action).toBeTruthy() |
|||
expect(toast.open).toBe(true) |
|||
}) |
|||
it('should dismiss a toast using returned dismiss function', () => { |
|||
const { result } = renderHook(() => useToast()) |
|||
vi.useFakeTimers() |
|||
|
|||
let toastRef: { id: string, dismiss: () => void } |
|||
|
|||
act(() => { |
|||
toastRef = result.current.toast({ title: 'Dismiss Me' }) |
|||
vi.runAllTimers() // Flush ADD_TOAST
|
|||
}) |
|||
|
|||
act(() => { |
|||
toastRef.dismiss() |
|||
}) |
|||
|
|||
const toast = result.current.toasts.find(t => t.id === toastRef.id) |
|||
expect(toast?.open).toBe(false) |
|||
|
|||
vi.useRealTimers() |
|||
}) |
|||
|
|||
|
|||
it('should allow dismiss via hook dismiss function', () => { |
|||
const { result } = renderHook(() => useToast()) |
|||
vi.useFakeTimers() |
|||
|
|||
let toastRef: { id: string } |
|||
|
|||
act(() => { |
|||
toastRef = result.current.toast({ title: 'Manual Dismiss' }) |
|||
vi.runAllTimers() |
|||
}) |
|||
|
|||
act(() => { |
|||
result.current.dismiss(toastRef.id) |
|||
}) |
|||
|
|||
const toast = result.current.toasts.find(t => t.id === toastRef.id) |
|||
expect(toast?.open).toBe(false) |
|||
|
|||
vi.useRealTimers() |
|||
}) |
|||
|
|||
}) |
|||
@ -0,0 +1,372 @@ |
|||
import { describe, it, expect, beforeEach, vi } from 'vitest'; |
|||
import { |
|||
useMessageStore, |
|||
MessageType, |
|||
MessageState, |
|||
type Message, |
|||
} from './messageStore.ts'; |
|||
|
|||
let memoryStorage: Record<string, string> = {}; |
|||
|
|||
vi.mock('./storage/indexDB.ts', () => { |
|||
console.log("Mocking zustandIndexDBStorage..."); |
|||
return { |
|||
zustandIndexDBStorage: { |
|||
getItem: vi.fn(async (name: string): Promise<string | null> => { |
|||
console.log(`Mock getItem: ${name}`, memoryStorage[name] ?? null); |
|||
return memoryStorage[name] ?? null; |
|||
}), |
|||
setItem: vi.fn(async (name: string, value: string): Promise<void> => { |
|||
console.log(`Mock setItem: ${name}`, value); |
|||
memoryStorage[name] = value; |
|||
}), |
|||
removeItem: vi.fn(async (name: string): Promise<void> => { |
|||
console.log(`Mock removeItem: ${name}`); |
|||
delete memoryStorage[name]; |
|||
}), |
|||
}, |
|||
}; |
|||
}); |
|||
|
|||
const myNodeNum = 111; |
|||
const otherNodeNum1 = 222; |
|||
const otherNodeNum2 = 333; |
|||
const broadcastChannel = 0; |
|||
|
|||
|
|||
|
|||
const directMessageToOther1: Message = { |
|||
type: MessageType.Direct, |
|||
from: myNodeNum, |
|||
to: otherNodeNum1, |
|||
channel: 0, |
|||
date: Date.now(), |
|||
messageId: 101, |
|||
state: MessageState.Waiting, |
|||
message: 'Hello other 1 from me', |
|||
}; |
|||
|
|||
const directMessageFromOther1: Message = { |
|||
type: MessageType.Direct, |
|||
from: otherNodeNum1, |
|||
to: myNodeNum, |
|||
channel: 0, |
|||
date: Date.now() + 1000, |
|||
messageId: 102, |
|||
state: MessageState.Waiting, |
|||
message: 'Hello me from other 1', |
|||
}; |
|||
|
|||
const directMessageToOther2: Message = { |
|||
type: MessageType.Direct, |
|||
from: myNodeNum, |
|||
to: otherNodeNum2, |
|||
channel: 0, |
|||
date: Date.now() + 2000, |
|||
messageId: 103, |
|||
state: MessageState.Waiting, |
|||
message: 'Hello other 2 from me', |
|||
}; |
|||
|
|||
const broadcastMessage1: Message = { |
|||
type: MessageType.Broadcast, |
|||
from: otherNodeNum1, |
|||
to: 0xffffffff, |
|||
channel: broadcastChannel, |
|||
date: Date.now() + 3000, |
|||
messageId: 201, |
|||
state: MessageState.Waiting, |
|||
message: 'Broadcast message 1', |
|||
}; |
|||
|
|||
const broadcastMessage2: Message = { |
|||
type: MessageType.Broadcast, |
|||
from: myNodeNum, |
|||
to: 0xffffffff, |
|||
channel: broadcastChannel, |
|||
date: Date.now() + 4000, |
|||
messageId: 202, |
|||
state: MessageState.Waiting, |
|||
message: 'Broadcast message 2', |
|||
}; |
|||
|
|||
describe('useMessageStore', () => { |
|||
const initialState = useMessageStore.getState(); |
|||
|
|||
beforeEach(() => { |
|||
useMessageStore.setState(initialState, true); |
|||
}); |
|||
|
|||
it('should have correct initial state', () => { |
|||
const state = useMessageStore.getState(); |
|||
expect(state.messages.direct).toEqual({}); |
|||
expect(state.messages.broadcast).toEqual({}); |
|||
expect(state.draft).toBeInstanceOf(Map); |
|||
expect(state.draft.size).toBe(0); |
|||
expect(state.nodeNum).toBe(0); |
|||
expect(state.activeChat).toBe(0); |
|||
expect(state.chatType).toBe(MessageType.Broadcast); |
|||
}); |
|||
|
|||
it('should set nodeNum', () => { |
|||
useMessageStore.getState().setNodeNum(myNodeNum); |
|||
expect(useMessageStore.getState().nodeNum).toBe(myNodeNum); |
|||
}); |
|||
|
|||
it('should set activeChat and chatType', () => { |
|||
useMessageStore.getState().setActiveChat(otherNodeNum1); |
|||
useMessageStore.getState().setChatType(MessageType.Direct); |
|||
expect(useMessageStore.getState().activeChat).toBe(otherNodeNum1); |
|||
expect(useMessageStore.getState().chatType).toBe(MessageType.Direct); |
|||
}); |
|||
|
|||
describe('saveMessage', () => { |
|||
it('should save a direct message with correct structure', () => { |
|||
useMessageStore.getState().saveMessage(directMessageToOther1); |
|||
const state = useMessageStore.getState(); |
|||
expect(state.messages.direct[myNodeNum]).toBeDefined(); |
|||
expect(state.messages.direct[myNodeNum][otherNodeNum1]).toBeDefined(); |
|||
expect( |
|||
state.messages.direct[myNodeNum][otherNodeNum1][directMessageToOther1.messageId], |
|||
).toEqual(directMessageToOther1); |
|||
}); |
|||
|
|||
it('should save a broadcast message with correct structure', () => { |
|||
useMessageStore.getState().saveMessage(broadcastMessage1); |
|||
const state = useMessageStore.getState(); |
|||
expect(state.messages.broadcast[broadcastChannel]).toBeDefined(); |
|||
expect( |
|||
state.messages.broadcast[broadcastChannel][broadcastMessage1.messageId], |
|||
).toEqual(broadcastMessage1); |
|||
}); |
|||
|
|||
it('should save multiple messages correctly', () => { |
|||
useMessageStore.getState().saveMessage(directMessageToOther1); |
|||
useMessageStore.getState().saveMessage(directMessageFromOther1); |
|||
useMessageStore.getState().saveMessage(broadcastMessage1); |
|||
|
|||
const state = useMessageStore.getState(); |
|||
|
|||
// Direct msg 1 (me -> other1)
|
|||
expect(state.messages.direct[myNodeNum]?.[otherNodeNum1]?.[directMessageToOther1.messageId]).toEqual(directMessageToOther1); |
|||
// Direct msg 2 (other1 -> me)
|
|||
expect(state.messages.direct[otherNodeNum1]?.[myNodeNum]?.[directMessageFromOther1.messageId]).toEqual(directMessageFromOther1); |
|||
// Broadcast msg 1
|
|||
expect(state.messages.broadcast[broadcastChannel]?.[broadcastMessage1.messageId]).toEqual(broadcastMessage1); |
|||
}); |
|||
}); |
|||
|
|||
describe('getMessages', () => { |
|||
beforeEach(() => { |
|||
useMessageStore.getState().setNodeNum(myNodeNum); |
|||
useMessageStore.getState().saveMessage(directMessageToOther1); |
|||
useMessageStore.getState().saveMessage(directMessageFromOther1); |
|||
useMessageStore.getState().saveMessage(directMessageToOther2); |
|||
useMessageStore.getState().saveMessage(broadcastMessage1); |
|||
useMessageStore.getState().saveMessage(broadcastMessage2); |
|||
}); |
|||
|
|||
it('should return broadcast messages for a channel, sorted by date', () => { |
|||
const messages = useMessageStore.getState().getMessages(MessageType.Broadcast, { |
|||
myNodeNum: myNodeNum, // Not strictly needed for broadcast, but good practice
|
|||
channel: broadcastChannel |
|||
}); |
|||
expect(messages).toHaveLength(2); |
|||
expect(messages[0]).toEqual(broadcastMessage1); |
|||
expect(messages[1]).toEqual(broadcastMessage2); |
|||
}); |
|||
|
|||
it('should return empty array for broadcast if channel has no messages', () => { |
|||
const messages = useMessageStore.getState().getMessages(MessageType.Broadcast, { |
|||
myNodeNum: myNodeNum, |
|||
channel: 99 |
|||
}); |
|||
expect(messages).toEqual([]); |
|||
}); |
|||
|
|||
it('should return combined direct messages for a specific chat (pair), sorted by date', () => { |
|||
const messages = useMessageStore.getState().getMessages(MessageType.Direct, { |
|||
myNodeNum: myNodeNum, |
|||
otherNodeNum: otherNodeNum1 |
|||
}); |
|||
expect(messages).toHaveLength(2); |
|||
expect(messages[0]).toEqual(directMessageToOther1); |
|||
expect(messages[1]).toEqual(directMessageFromOther1); |
|||
}); |
|||
|
|||
it('should return only relevant direct messages for a different chat pair', () => { |
|||
const messages = useMessageStore.getState().getMessages(MessageType.Direct, { |
|||
myNodeNum: myNodeNum, |
|||
otherNodeNum: otherNodeNum2 |
|||
}); |
|||
expect(messages).toHaveLength(1); |
|||
expect(messages[0]).toEqual(directMessageToOther2); |
|||
}); |
|||
|
|||
it('should return empty array for direct chat if no messages exist', () => { |
|||
const messages = useMessageStore.getState().getMessages(MessageType.Direct, { |
|||
myNodeNum: myNodeNum, |
|||
otherNodeNum: 999 |
|||
}); |
|||
expect(messages).toEqual([]); |
|||
}); |
|||
|
|||
it('should return empty array if myNodeNum is not provided for direct messages', () => { |
|||
const messages = useMessageStore.getState().getMessages(MessageType.Direct, { |
|||
otherNodeNum: otherNodeNum1 |
|||
}); |
|||
expect(messages).toEqual([]); |
|||
}); |
|||
}); |
|||
|
|||
describe('setMessageState', () => { |
|||
beforeEach(() => { |
|||
useMessageStore.getState().setNodeNum(myNodeNum); |
|||
useMessageStore.getState().saveMessage(directMessageToOther1); |
|||
useMessageStore.getState().saveMessage(directMessageFromOther1); |
|||
useMessageStore.getState().saveMessage(broadcastMessage1); |
|||
}); |
|||
|
|||
it('should update state for a direct message sent BY ME', () => { |
|||
useMessageStore.getState().setMessageState({ |
|||
type: MessageType.Direct, |
|||
key: otherNodeNum1, |
|||
messageId: directMessageToOther1.messageId, |
|||
newState: MessageState.Ack, |
|||
}); |
|||
const message = useMessageStore.getState().messages.direct[myNodeNum]?.[otherNodeNum1]?.[directMessageToOther1.messageId]; |
|||
expect(message?.state).toBe(MessageState.Ack); |
|||
}); |
|||
|
|||
it('should update state for a direct message received FROM OTHER', () => { |
|||
useMessageStore.getState().setMessageState({ |
|||
type: MessageType.Direct, |
|||
key: otherNodeNum1, |
|||
messageId: directMessageFromOther1.messageId, |
|||
newState: MessageState.Failed, |
|||
}); |
|||
const message = useMessageStore.getState().messages.direct[otherNodeNum1]?.[myNodeNum]?.[directMessageFromOther1.messageId]; |
|||
expect(message?.state).toBe(MessageState.Failed); |
|||
}); |
|||
|
|||
it('should update state for a broadcast message', () => { |
|||
useMessageStore.getState().setMessageState({ |
|||
type: MessageType.Broadcast, |
|||
key: broadcastChannel, |
|||
messageId: broadcastMessage1.messageId, |
|||
newState: MessageState.Ack, |
|||
}); |
|||
const message = useMessageStore.getState().messages.broadcast[broadcastChannel]?.[broadcastMessage1.messageId]; |
|||
expect(message?.state).toBe(MessageState.Ack); |
|||
}); |
|||
|
|||
it('should warn if message is not found', () => { |
|||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => { }); |
|||
useMessageStore.getState().setMessageState({ |
|||
type: MessageType.Direct, |
|||
key: otherNodeNum1, |
|||
messageId: 999, |
|||
newState: MessageState.Ack, |
|||
}); |
|||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Message not found for state update')); |
|||
warnSpy.mockRestore(); |
|||
}); |
|||
}); |
|||
|
|||
|
|||
describe('clearMessageByMessageId', () => { |
|||
beforeEach(() => { |
|||
useMessageStore.getState().setNodeNum(myNodeNum); |
|||
useMessageStore.getState().saveMessage(directMessageToOther1); |
|||
useMessageStore.getState().saveMessage(directMessageFromOther1); |
|||
useMessageStore.getState().saveMessage(broadcastMessage1); |
|||
useMessageStore.getState().saveMessage({ ...directMessageToOther1, messageId: 1011, date: Date.now() + 50 }); |
|||
}); |
|||
|
|||
it('should delete a specific direct message (sent by me)', () => { |
|||
const messageIdToDelete = directMessageToOther1.messageId; |
|||
useMessageStore.getState().clearMessageByMessageId({ |
|||
type: MessageType.Direct, |
|||
sender: myNodeNum, |
|||
recipient: otherNodeNum1, |
|||
messageId: messageIdToDelete |
|||
}); |
|||
const state = useMessageStore.getState(); |
|||
expect(state.messages.direct[myNodeNum]?.[otherNodeNum1]?.[messageIdToDelete]).toBeUndefined(); |
|||
expect(state.messages.direct[myNodeNum]?.[otherNodeNum1]?.[1011]).toBeDefined(); |
|||
expect(state.messages.direct[otherNodeNum1]?.[myNodeNum]?.[directMessageFromOther1.messageId]).toBeDefined(); |
|||
}); |
|||
|
|||
it('should delete a specific direct message (sent by other)', () => { |
|||
const messageIdToDelete = directMessageFromOther1.messageId; |
|||
useMessageStore.getState().clearMessageByMessageId({ |
|||
type: MessageType.Direct, |
|||
sender: otherNodeNum1, |
|||
recipient: myNodeNum, |
|||
messageId: messageIdToDelete |
|||
}); |
|||
const state = useMessageStore.getState(); |
|||
expect(state.messages.direct[otherNodeNum1]?.[myNodeNum]?.[messageIdToDelete]).toBeUndefined(); |
|||
expect(state.messages.direct[myNodeNum]?.[otherNodeNum1]?.[directMessageToOther1.messageId]).toBeDefined(); |
|||
expect(state.messages.direct[myNodeNum]?.[otherNodeNum1]?.[1011]).toBeDefined(); |
|||
}); |
|||
|
|||
it('should delete a specific broadcast message', () => { |
|||
const messageIdToDelete = broadcastMessage1.messageId; |
|||
useMessageStore.getState().clearMessageByMessageId({ |
|||
type: MessageType.Broadcast, |
|||
channel: broadcastChannel, |
|||
messageId: messageIdToDelete |
|||
}); |
|||
const state = useMessageStore.getState(); |
|||
expect(state.messages.broadcast[broadcastChannel]?.[messageIdToDelete]).toBeUndefined(); |
|||
}); |
|||
|
|||
it('should clean up empty recipient/sender/channel objects', () => { |
|||
useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Direct, sender: otherNodeNum1, recipient: myNodeNum, messageId: directMessageFromOther1.messageId }); |
|||
expect(useMessageStore.getState().messages.direct[otherNodeNum1]?.[myNodeNum]).toBeUndefined(); // Recipient level removed
|
|||
expect(useMessageStore.getState().messages.direct[otherNodeNum1]).toBeUndefined(); // Sender level removed
|
|||
|
|||
useMessageStore.getState().clearMessageByMessageId({ type: MessageType.Broadcast, channel: broadcastChannel, messageId: broadcastMessage1.messageId }); |
|||
expect(useMessageStore.getState().messages.broadcast[broadcastChannel]).toBeUndefined(); // Channel level removed
|
|||
}); |
|||
}); |
|||
|
|||
describe('Drafts', () => { |
|||
const draftKey = otherNodeNum1; |
|||
const draftMessage = 'This is a draft'; |
|||
|
|||
it('should set and get a draft', () => { |
|||
useMessageStore.getState().setDraft(draftKey, draftMessage); |
|||
expect(useMessageStore.getState().draft.get(draftKey)).toBe(draftMessage); |
|||
expect(useMessageStore.getState().getDraft(draftKey)).toBe(draftMessage); |
|||
}); |
|||
|
|||
it('should return empty string for non-existent draft', () => { |
|||
expect(useMessageStore.getState().getDraft(999)).toBe(''); |
|||
}); |
|||
|
|||
it('should clear a draft', () => { |
|||
useMessageStore.getState().setDraft(draftKey, draftMessage); |
|||
expect(useMessageStore.getState().draft.has(draftKey)).toBe(true); |
|||
useMessageStore.getState().clearDraft(draftKey); |
|||
expect(useMessageStore.getState().draft.has(draftKey)).toBe(false); |
|||
expect(useMessageStore.getState().getDraft(draftKey)).toBe(''); |
|||
}); |
|||
}); |
|||
|
|||
describe('clearAllMessages', () => { |
|||
it('should clear all direct and broadcast messages', () => { |
|||
useMessageStore.getState().saveMessage(directMessageToOther1); |
|||
useMessageStore.getState().saveMessage(broadcastMessage1); |
|||
expect(Object.keys(useMessageStore.getState().messages.direct).length).toBeGreaterThan(0); |
|||
expect(Object.keys(useMessageStore.getState().messages.broadcast).length).toBeGreaterThan(0); |
|||
|
|||
useMessageStore.getState().clearAllMessages(); |
|||
|
|||
expect(useMessageStore.getState().messages.direct).toEqual({}); |
|||
expect(useMessageStore.getState().messages.broadcast).toEqual({}); |
|||
}); |
|||
}); |
|||
|
|||
}); |
|||
@ -0,0 +1,234 @@ |
|||
import { create } from 'zustand'; |
|||
import { persist, createJSONStorage } from 'zustand/middleware'; |
|||
import { produce } from 'immer'; |
|||
import { Types } from '@meshtastic/core'; |
|||
import { zustandIndexDBStorage } from "./storage/indexDB.ts"; |
|||
|
|||
export enum MessageState { |
|||
Ack = "ack", |
|||
Waiting = "waiting", |
|||
Failed = "failed", |
|||
} |
|||
|
|||
export enum MessageType { |
|||
Direct = "direct", |
|||
Broadcast = "broadcast", |
|||
} |
|||
|
|||
interface MessageBase { |
|||
channel: Types.ChannelNumber; |
|||
to: number; |
|||
from: number; |
|||
date: number; |
|||
messageId: number; |
|||
state: MessageState; |
|||
message: string; |
|||
} |
|||
|
|||
interface GenericMessage<T extends MessageType> extends MessageBase { |
|||
type: T; |
|||
} |
|||
|
|||
export type Message = GenericMessage<MessageType.Direct> | GenericMessage<MessageType.Broadcast>; |
|||
|
|||
export interface MessageStore { |
|||
messages: { |
|||
direct: Record<number, Record<number, Record<number, Message>>>; |
|||
broadcast: Record<number, Record<number, Message>>; // channel -> messageId -> Message
|
|||
}; |
|||
draft: Map<Types.Destination, string>; |
|||
nodeNum: number; // This device's node number
|
|||
activeChat: number; // Represents otherNodeNum for Direct, or channel for Broadcast
|
|||
chatType: MessageType; |
|||
|
|||
setNodeNum: (nodeNum: number) => void; |
|||
getNodeNum: () => number; |
|||
setActiveChat: (chat: number) => void; |
|||
setChatType: (type: MessageType) => void; |
|||
saveMessage: (message: Message) => void; |
|||
setMessageState: (params: { |
|||
type: MessageType; |
|||
// For Direct: Represents the *other* node number involved in the chat.
|
|||
// For Broadcast: Represents the channel number.
|
|||
key: number; |
|||
messageId: number; |
|||
newState?: MessageState; |
|||
}) => void; |
|||
getMessages: (type: MessageType, options: { myNodeNum: number; otherNodeNum?: number; channel?: number }) => Message[]; |
|||
getDraft: (key: Types.Destination) => string; |
|||
setDraft: (key: Types.Destination, message: string) => void; |
|||
clearAllMessages: () => void; |
|||
clearMessageByMessageId: (params: { |
|||
type: MessageType; |
|||
sender?: number; |
|||
recipient?: number; |
|||
channel?: number; |
|||
messageId: number |
|||
}) => void; |
|||
clearDraft: (key: Types.Destination) => void; |
|||
} |
|||
|
|||
const CURRENT_STORE_VERSION = 0; |
|||
|
|||
export const useMessageStore = create<MessageStore>()( |
|||
persist( |
|||
(set, get) => ({ |
|||
messages: { |
|||
direct: {}, // Record<sender, Record<recipient, Record<messageId, Message>>>
|
|||
broadcast: {}, |
|||
}, |
|||
draft: new Map<number, string>(), |
|||
activeChat: 0, |
|||
chatType: MessageType.Broadcast, |
|||
nodeNum: 0, |
|||
setNodeNum: (nodeNum) => { |
|||
set(produce((state: MessageStore) => { |
|||
state.nodeNum = nodeNum; |
|||
})); |
|||
}, |
|||
getNodeNum: () => get().nodeNum, |
|||
setActiveChat: (chat) => { |
|||
set(produce((state: MessageStore) => { |
|||
state.activeChat = chat; |
|||
})); |
|||
}, |
|||
setChatType: (type) => { |
|||
set(produce((state: MessageStore) => { |
|||
state.chatType = type; |
|||
})); |
|||
}, |
|||
saveMessage: (message) => { |
|||
set(produce((state: MessageStore) => { |
|||
if (message.type === MessageType.Direct) { |
|||
const sender = Number(message.from); |
|||
const recipient = Number(message.to); |
|||
|
|||
if (!state.messages.direct[sender]) { |
|||
state.messages.direct[sender] = {}; |
|||
} |
|||
if (!state.messages.direct[sender][recipient]) { |
|||
state.messages.direct[sender][recipient] = {}; |
|||
} |
|||
state.messages.direct[sender][recipient][message.messageId] = message; |
|||
|
|||
} else if (message.type === MessageType.Broadcast) { |
|||
const channel = Number(message.channel); |
|||
if (!state.messages.broadcast[channel]) { |
|||
state.messages.broadcast[channel] = {}; |
|||
} |
|||
state.messages.broadcast[channel][message.messageId] = message; |
|||
} |
|||
})); |
|||
}, |
|||
setMessageState: ({ |
|||
type, |
|||
key, |
|||
messageId, |
|||
newState = MessageState.Ack, |
|||
}) => { |
|||
set( |
|||
produce((state: MessageStore) => { |
|||
let message: Message | undefined; |
|||
|
|||
if (type === MessageType.Broadcast) { |
|||
const channel = key; |
|||
message = state.messages.broadcast?.[channel]?.[messageId]; |
|||
} else if (type === MessageType.Direct) { |
|||
const otherNodeNum = key; |
|||
const myNodeNum = state.nodeNum; |
|||
|
|||
message = state.messages.direct?.[myNodeNum]?.[otherNodeNum]?.[messageId]; |
|||
|
|||
if (!message) { |
|||
message = state.messages.direct?.[otherNodeNum]?.[myNodeNum]?.[messageId]; |
|||
} |
|||
} |
|||
|
|||
if (message) { |
|||
message.state = newState; |
|||
} else { |
|||
console.warn(`Message not found for state update - type: ${type}, key (otherNode/channel): ${key}, messageId: ${messageId}, myNodeNum: ${state.nodeNum}`); |
|||
} |
|||
}), |
|||
); |
|||
}, |
|||
getMessages: (type, options) => { |
|||
const state = get(); |
|||
|
|||
if (type === MessageType.Broadcast && options.channel !== undefined) { |
|||
const messageMap = state.messages.broadcast[options.channel] ?? {}; |
|||
return Object.values(messageMap).sort((a, b) => a.date - b.date); |
|||
} |
|||
|
|||
if (type === MessageType.Direct && options.myNodeNum !== undefined && options.otherNodeNum !== undefined) { |
|||
const myNodeNum = options.myNodeNum; |
|||
const otherNodeNum = options.otherNodeNum; |
|||
|
|||
// Messages sent BY ME TO OTHER
|
|||
const sentByMeMap = state.messages.direct?.[myNodeNum]?.[otherNodeNum] ?? {}; |
|||
const sentByMe = Object.values(sentByMeMap); |
|||
|
|||
// Messages sent BY OTHER TO ME
|
|||
const sentByOtherMap = state.messages.direct?.[otherNodeNum]?.[myNodeNum] ?? {}; |
|||
const sentByOther = Object.values(sentByOtherMap); |
|||
|
|||
// Merge and sort chronologically
|
|||
return [...sentByMe, ...sentByOther].sort((a, b) => a.date - b.date); |
|||
} |
|||
return []; |
|||
}, |
|||
clearMessageByMessageId: ({ type, sender, recipient, channel, messageId }) => { |
|||
set(produce((state: MessageStore) => { |
|||
if (type === MessageType.Broadcast && channel !== undefined) { |
|||
const messageMap = state.messages.broadcast[channel]; |
|||
if (messageMap?.[messageId]) { |
|||
delete messageMap[messageId]; |
|||
if (Object.keys(messageMap).length === 0) { |
|||
delete state.messages.broadcast[channel]; |
|||
} |
|||
} |
|||
} else if (type === MessageType.Direct && sender !== undefined && recipient !== undefined) { |
|||
const messageMap = state.messages.direct?.[sender]?.[recipient]; |
|||
if (messageMap?.[messageId]) { |
|||
delete messageMap[messageId]; |
|||
if (Object.keys(messageMap).length === 0) { |
|||
delete state.messages.direct[sender][recipient]; |
|||
if (Object.keys(state.messages.direct[sender]).length === 0) { |
|||
delete state.messages.direct[sender]; |
|||
} |
|||
} |
|||
} |
|||
console.warn("clearMessageByMessageId called without sufficient identifiers for type", type); |
|||
} |
|||
})); |
|||
}, |
|||
getDraft: (key) => { |
|||
return get().draft.get(key) ?? ''; |
|||
}, |
|||
setDraft: (key, message) => { |
|||
set(produce((state: MessageStore) => { |
|||
state.draft.set(key, message); |
|||
})); |
|||
}, |
|||
clearDraft: (key) => { |
|||
set(produce((state: MessageStore) => { |
|||
state.draft.delete(key); |
|||
})); |
|||
}, |
|||
clearAllMessages: () => { |
|||
set(produce((state: MessageStore) => { |
|||
state.messages.direct = {}; |
|||
state.messages.broadcast = {}; |
|||
})); |
|||
} |
|||
}), |
|||
{ |
|||
name: 'meshtastic-message-store', |
|||
storage: createJSONStorage(() => zustandIndexDBStorage), |
|||
version: CURRENT_STORE_VERSION, |
|||
partialize: (state) => ({ |
|||
messages: state.messages, |
|||
nodeNum: state.nodeNum, |
|||
}), |
|||
} |
|||
)); |
|||
@ -0,0 +1,14 @@ |
|||
import { StateStorage } from "zustand/middleware"; |
|||
import { get, set, del } from "idb-keyval"; |
|||
|
|||
export const zustandIndexDBStorage: StateStorage = { |
|||
getItem: async (name: string): Promise<string | null> => { |
|||
return (await get(name)) || null; |
|||
}, |
|||
setItem: async (name: string, value: string): Promise<void> => { |
|||
await set(name, value); |
|||
}, |
|||
removeItem: async (name: string): Promise<void> => { |
|||
await del(name); |
|||
}, |
|||
}; |
|||
@ -0,0 +1,13 @@ |
|||
import { ZodError, ZodSchema } from "zod"; |
|||
|
|||
export function validateSchema<T>( |
|||
schema: ZodSchema<T>, |
|||
data: unknown |
|||
): { success: true; data: T } | { success: false; errors: ZodError["issues"] } { |
|||
const result = schema.safeParse(data); |
|||
if (result.success) { |
|||
return { success: true, data: result.data }; |
|||
} else { |
|||
return { success: false, errors: result.error.issues }; |
|||
} |
|||
} |
|||
Loading…
Reference in new issue