committed by
GitHub
42 changed files with 1799 additions and 1299 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", |
"name": "meshtastic-web", |
||||
"version": "2.3.3-0", |
"version": "2.6.0-0", |
||||
"type": "module", |
"type": "module", |
||||
"description": "Meshtastic web client", |
"description": "Meshtastic web client", |
||||
"license": "GPL-3.0-only", |
"license": "GPL-3.0-only", |
||||
@ -34,11 +34,12 @@ |
|||||
}, |
}, |
||||
"homepage": "https://meshtastic.org", |
"homepage": "https://meshtastic.org", |
||||
"dependencies": { |
"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/js": "npm:@jsr/[email protected]", |
||||
"@meshtastic/transport-http": "npm:@jsr/meshtastic__transport-http", |
"@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", |
"@meshtastic/transport-web-serial": "npm:@jsr/meshtastic__transport-web-serial", |
||||
|
"@bufbuild/protobuf": "^2.2.5", |
||||
"@noble/curves": "^1.8.1", |
"@noble/curves": "^1.8.1", |
||||
"@radix-ui/react-accordion": "^1.2.3", |
"@radix-ui/react-accordion": "^1.2.3", |
||||
"@radix-ui/react-checkbox": "^1.1.4", |
"@radix-ui/react-checkbox": "^1.1.4", |
||||
@ -59,49 +60,50 @@ |
|||||
"class-validator": "^0.14.1", |
"class-validator": "^0.14.1", |
||||
"class-variance-authority": "^0.7.1", |
"class-variance-authority": "^0.7.1", |
||||
"clsx": "^2.1.1", |
"clsx": "^2.1.1", |
||||
"cmdk": "^1.0.4", |
"cmdk": "^1.1.1", |
||||
"crypto-random-string": "^5.0.0", |
"crypto-random-string": "^5.0.0", |
||||
"immer": "^10.1.1", |
"immer": "^10.1.1", |
||||
"js-cookie": "^3.0.5", |
"js-cookie": "^3.0.5", |
||||
"lucide-react": "^0.477.0", |
"lucide-react": "^0.486.0", |
||||
"maplibre-gl": "5.1.1", |
"maplibre-gl": "5.3.0", |
||||
"react": "^19.0.0", |
"react": "^19.1.0", |
||||
"react-dom": "^19.0.0", |
"react-dom": "^19.1.0", |
||||
"react-error-boundary": "^5.0.0", |
"react-error-boundary": "^5.0.0", |
||||
"react-hook-form": "^7.54.2", |
"react-hook-form": "^7.55.0", |
||||
"react-map-gl": "8.0.1", |
"react-map-gl": "8.0.2", |
||||
"react-qrcode-logo": "^3.0.0", |
"react-qrcode-logo": "^3.0.0", |
||||
"rfc4648": "^1.5.4", |
"rfc4648": "^1.5.4", |
||||
"vite-plugin-node-polyfills": "^0.23.0", |
"vite-plugin-node-polyfills": "^0.23.0", |
||||
|
"zod": "^3.24.2", |
||||
"zustand": "5.0.3" |
"zustand": "5.0.3" |
||||
}, |
}, |
||||
"devDependencies": { |
"devDependencies": { |
||||
"@tailwindcss/postcss": "^4.0.9", |
"@tailwindcss/postcss": "^4.1.0", |
||||
"@testing-library/jest-dom": "^6.6.3", |
"@testing-library/jest-dom": "^6.6.3", |
||||
"@testing-library/react": "^16.2.0", |
"@testing-library/react": "^16.2.0", |
||||
"@testing-library/user-event": "^14.6.1", |
"@testing-library/user-event": "^14.6.1", |
||||
"@types/chrome": "^0.0.307", |
"@types/chrome": "^0.0.313", |
||||
"@types/js-cookie": "^3.0.6", |
"@types/js-cookie": "^3.0.6", |
||||
"@types/node": "^22.13.7", |
"@types/node": "^22.13.17", |
||||
"@types/react": "^19.0.10", |
"@types/react": "^19.0.12", |
||||
"@types/react-dom": "^19.0.4", |
"@types/react-dom": "^19.0.4", |
||||
"@types/serviceworker": "^0.0.123", |
"@types/serviceworker": "^0.0.127", |
||||
"@types/w3c-web-serial": "^1.0.8", |
"@types/w3c-web-serial": "^1.0.8", |
||||
"@types/web-bluetooth": "^0.0.21", |
"@types/web-bluetooth": "^0.0.21", |
||||
"@vitejs/plugin-react": "^4.3.4", |
"@vitejs/plugin-react": "^4.3.4", |
||||
"autoprefixer": "^10.4.20", |
"autoprefixer": "^10.4.21", |
||||
"gzipper": "^8.2.0", |
"gzipper": "^8.2.1", |
||||
"happy-dom": "^17.2.2", |
"happy-dom": "^17.4.4", |
||||
"postcss": "^8.5.3", |
"postcss": "^8.5.3", |
||||
"simple-git-hooks": "^2.11.1", |
"simple-git-hooks": "^2.12.1", |
||||
"tailwind-merge": "^3.0.2", |
"tailwind-merge": "^3.1.0", |
||||
"tailwindcss": "^4.0.9", |
"tailwindcss": "^4.1.0", |
||||
"tailwindcss-animate": "^1.0.7", |
"tailwindcss-animate": "^1.0.7", |
||||
"tar": "^7.4.3", |
"tar": "^7.4.3", |
||||
"testing-library": "^0.0.2", |
"testing-library": "^0.0.2", |
||||
"typescript": "^5.8.2", |
"typescript": "^5.8.2", |
||||
"vite": "^6.2.0", |
"vite": "^6.2.4", |
||||
"vitest": "^3.0.7", |
"vitest": "^3.1.1", |
||||
"vite-plugin-pwa": "^0.21.1" |
"vite-plugin-pwa": "^1.0.0" |
||||
} |
} |
||||
} |
} |
||||
|
|||||
@ -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" |
||||
|
}) |
||||
|
} |
||||
|
}) |
||||
|
); |
||||
|
}); |
||||
|
}); |
||||
|
}); |
||||
@ -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,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