Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/web/public/i18n/locales/en/ui.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@
"title": "Saved",
"description": "All configuration changes have been saved."
},
"savePending": {
"title": "Saved",
"description": "Changes made while saving haven't been sent yet. Save again to send them."
},
"favoriteNode": {
"title": "{{action}} {{nodeName}} {{direction}} favorites.",
"action": {
Expand Down
87 changes: 87 additions & 0 deletions apps/web/src/components/PageComponents/ModuleConfig/MQTT.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { create } from "@bufbuild/protobuf";
import { renderSettingsForm } from "@app/tests/renderSettingsForm.tsx";
import { Protobuf } from "@meshtastic/sdk";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { MQTT } from "./MQTT.tsx";

// Protobuf omits default booleans on the wire, so these tests decode what was
// actually sent rather than trusting the editor's state.

const deviceMqtt = () =>
create(Protobuf.ModuleConfig.ModuleConfigSchema, {
payloadVariant: {
case: "mqtt",
value: create(Protobuf.ModuleConfig.ModuleConfig_MQTTConfigSchema, {
enabled: false,
address: "mqtt.example.org",
username: "meshdev",
password: "large4cats",
root: "msh",
}),
},
});

const renderMqtt = () =>
renderSettingsForm(<MQTT onFormInit={() => {}} />, {
moduleConfig: [deviceMqtt()],
});

describe("MQTT module config", () => {
it("puts `enabled: true` on the wire when the toggle is switched on", async () => {
const { editor, radio } = renderMqtt();

await userEvent.click(await screen.findByLabelText("MQTT Enabled"));
await waitFor(() =>
expect(editor.dirtyModuleSections.value).toContain("mqtt"),
);
expect((await editor.commit()).status).toBe("ok");

const wire = radio.sentModuleConfig("mqtt");
expect(wire?.enabled).toBe(true);
// Fields the user didn't touch still round-trip.
expect(wire?.address).toBe("mqtt.example.org");
expect(wire?.username).toBe("meshdev");
expect(wire?.password).toBe("large4cats");
expect(wire?.root).toBe("msh");
});

it("keeps the toggle pending when it was flipped during another save", async () => {
const { client, editor, radio } = renderMqtt();
client.events.onConfigPacket.dispatch(
create(Protobuf.Config.ConfigSchema, {
payloadVariant: {
case: "lora",
value: create(Protobuf.Config.Config_LoRaConfigSchema, { region: 1 }),
},
}),
);
editor.setRadioSection(
"lora",
create(Protobuf.Config.Config_LoRaConfigSchema, { region: 4 }),
);

const held = radio.hold("commitEditSettings");
const pending = editor.commit();
await held.reached;

await userEvent.click(await screen.findByLabelText("MQTT Enabled"));
await waitFor(() =>
expect(editor.dirtyModuleSections.value).toContain("mqtt"),
);
held.release();
expect((await pending).status).toBe("ok");

expect(radio.cases()).toEqual([
"beginEditSettings",
"setConfig",
"commitEditSettings",
]);
expect(editor.dirtyModuleSections.value).toContain("mqtt");

expect((await editor.commit()).status).toBe("ok");
expect(radio.sentModuleConfig("mqtt")?.enabled).toBe(true);
expect(editor.isDirty.value).toBe(false);
});
});
128 changes: 128 additions & 0 deletions apps/web/src/components/PageComponents/Settings/DeviceConfig.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { create } from "@bufbuild/protobuf";
import { renderSettingsForm } from "@app/tests/renderSettingsForm.tsx";
import { BluetoothValidationSchema } from "@app/validation/config/bluetooth.ts";
import { Bluetooth } from "@components/PageComponents/Settings/Bluetooth.tsx";
import { Device } from "@components/PageComponents/Settings/Device/index.tsx";
import { Protobuf } from "@meshtastic/sdk";
import { screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";

// Protobuf omits default booleans on the wire, so these tests decode what was
// actually sent rather than trusting the editor's state.

const deviceConfig = () =>
create(Protobuf.Config.ConfigSchema, {
payloadVariant: {
case: "device",
value: create(Protobuf.Config.Config_DeviceConfigSchema, {
role: Protobuf.Config.Config_DeviceConfig_Role.CLIENT,
buttonGpio: 4,
buzzerGpio: 2,
nodeInfoBroadcastSecs: 10800,
doubleTapAsButtonPress: false,
}),
},
});

const bluetoothConfig = (fixedPin: number) =>
create(Protobuf.Config.ConfigSchema, {
payloadVariant: {
case: "bluetooth",
value: create(Protobuf.Config.Config_BluetoothConfigSchema, {
enabled: false,
mode: Protobuf.Config.Config_BluetoothConfig_PairingMode.RANDOM_PIN,
fixedPin,
}),
},
});

const primaryChannel = (uplinkEnabled: boolean) =>
create(Protobuf.Channel.ChannelSchema, {
index: 0,
role: Protobuf.Channel.Channel_Role.PRIMARY,
settings: create(Protobuf.Channel.ChannelSettingsSchema, {
psk: new Uint8Array([1]),
uplinkEnabled,
}),
});

const renderDevice = () =>
renderSettingsForm(<Device onFormInit={() => {}} />, {
config: [deviceConfig()],
});

describe("Device settings", () => {
it("puts a toggled device-config flag on the wire", async () => {
const { editor, radio } = renderDevice();

await userEvent.click(await screen.findByLabelText("Double Tap as Button"));
await waitFor(() =>
expect(editor.dirtyRadioSections.value).toContain("device"),
);
expect((await editor.commit()).status).toBe("ok");

const wire = radio.sentConfig("device");
expect(wire?.doubleTapAsButtonPress).toBe(true);
expect(wire?.buttonGpio).toBe(4);
expect(wire?.buzzerGpio).toBe(2);
expect(wire?.nodeInfoBroadcastSecs).toBe(10800);
expect(wire?.role).toBe(Protobuf.Config.Config_DeviceConfig_Role.CLIENT);
});

it("keeps the toggle pending when it was flipped during another save", async () => {
const { client, editor, radio } = renderDevice();
client.events.onChannelPacket.dispatch(primaryChannel(false));
editor.setChannel(primaryChannel(true));

const held = radio.hold("commitEditSettings");
const pending = editor.commit();
await held.reached;

await userEvent.click(await screen.findByLabelText("Double Tap as Button"));
await waitFor(() =>
expect(editor.dirtyRadioSections.value).toContain("device"),
);
held.release();
expect((await pending).status).toBe("ok");

expect(radio.cases()).toEqual([
"beginEditSettings",
"setChannel",
"commitEditSettings",
]);
expect(editor.dirtyRadioSections.value).toContain("device");

expect((await editor.commit()).status).toBe("ok");
expect(radio.sentConfig("device")?.doubleTapAsButtonPress).toBe(true);
expect(editor.isDirty.value).toBe(false);
});

it("saves Bluetooth on a device that has no fixed PIN", async () => {
// Radios that never used FIXED_PIN pairing report a PIN of 0.
const { editor, radio } = renderSettingsForm(
<Bluetooth onFormInit={() => {}} />,
{ config: [bluetoothConfig(0)] },
);

await userEvent.click(await screen.findByLabelText("Bluetooth enabled"));
await waitFor(() =>
expect(editor.dirtyRadioSections.value).toContain("bluetooth"),
);
expect((await editor.commit()).status).toBe("ok");

expect(radio.sentConfig("bluetooth")?.enabled).toBe(true);
});

it("still requires a six-digit PIN for FIXED_PIN pairing", () => {
const { FIXED_PIN, RANDOM_PIN } =
Protobuf.Config.Config_BluetoothConfig_PairingMode;
const parse = (mode: number, fixedPin: number) =>
BluetoothValidationSchema.safeParse({ enabled: true, mode, fixedPin })
.success;

expect(parse(FIXED_PIN, 0)).toBe(false);
expect(parse(FIXED_PIN, 123456)).toBe(true);
expect(parse(RANDOM_PIN, 0)).toBe(true);
});
});
10 changes: 10 additions & 0 deletions apps/web/src/pages/Settings/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,16 @@ const ConfigPage = () => {
if (result.status === "error") {
throw result.error;
}

// Edits made while the save was in flight are still pending.
if (editor.isDirty.value) {
toast({
title: t("toast.savePending.title"),
description: t("toast.savePending.description"),
});
return;
}

toast({
title: t("toast.saveAllSuccess.title"),
description: t("toast.saveAllSuccess.description"),
Expand Down
64 changes: 64 additions & 0 deletions apps/web/src/tests/renderSettingsForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { CurrentDeviceContext, useDeviceStore } from "@core/stores";
import { MeshClient, MeshRegistry, type Protobuf } from "@meshtastic/sdk";
import {
createFakeTransport,
interceptAdminMessages,
} from "@meshtastic/sdk/testing";
import { MeshRegistryProvider } from "@meshtastic/sdk-react";
import { cleanup, render } from "@testing-library/react";
import { type ReactElement, Suspense } from "react";
import { afterEach } from "vitest";

let nextDeviceId = 1000;
const teardowns: (() => void)[] = [];

afterEach(() => {
// Unmount before removing the device the form reads from.
cleanup();
for (const teardown of teardowns.splice(0)) teardown();
});

/**
* Render a settings form against a connected fake radio, with `device` as the
* config it has reported. Torn down after each test.
*/
export function renderSettingsForm(
ui: ReactElement,
device: {
config?: Protobuf.Config.Config[];
moduleConfig?: Protobuf.ModuleConfig.ModuleConfig[];
},
) {
const { transport } = createFakeTransport();
const registry = new MeshRegistry();
const client = new MeshClient({ transport });
const id = nextDeviceId++;
registry.register(id, client);
registry.setActive(id);
const radio = interceptAdminMessages(client);

const stored = useDeviceStore.getState().addDevice(id);
for (const config of device.config ?? []) {
stored.setConfig(config);
client.events.onConfigPacket.dispatch(config);
}
for (const moduleConfig of device.moduleConfig ?? []) {
stored.setModuleConfig(moduleConfig);
client.events.onModuleConfigPacket.dispatch(moduleConfig);
}

teardowns.push(() => {
registry.unregister(id);
useDeviceStore.getState().removeDevice(id);
});

render(
<CurrentDeviceContext.Provider value={{ deviceId: id }}>
<MeshRegistryProvider registry={registry}>
<Suspense fallback={<div>loading</div>}>{ui}</Suspense>
</MeshRegistryProvider>
</CurrentDeviceContext.Provider>,
);

return { client, editor: client.config.editor, radio };
}
5 changes: 5 additions & 0 deletions packages/sdk/src/core/testing/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
export { createFakeTransport } from "./createFakeTransport.ts";
export {
type AdminMessageInterceptor,
type HeldSend,
interceptAdminMessages,
} from "./interceptAdminMessages.ts";
export type {
FakeResponder,
FakeTransportHandle,
Expand Down
Loading
Loading