Skip to content
Open
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
1 change: 1 addition & 0 deletions apps/web/public/i18n/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"kilohertz": "kHz",
"raw": "raw",
"meter": { "one": "Meter", "plural": "Meters", "suffix": "m" },
"foot": { "one": "Foot", "plural": "Feet", "suffix": "ft" },
"kilometer": { "one": "Kilometer", "plural": "Kilometers", "suffix": "km" },
"minute": { "one": "Minute", "plural": "Minutes" },
"hour": { "one": "Hour", "plural": "Hours" },
Expand Down
247 changes: 247 additions & 0 deletions apps/web/src/components/PageComponents/Settings/Position.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
import { create } from "@bufbuild/protobuf";
import { Protobuf } from "@meshtastic/sdk";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Position } from "./Position.tsx";

// Integration/component test for meshtastic/web#1051.
//
// The fixed-position altitude field is labelled in Feet when the display units
// are Imperial, but the firmware/protobuf store altitude as integer meters.
// This test renders the real <Position /> form (DynamicForm + react-hook-form +
// the zod resolver) and asserts the conversion happens at the edges:
// - form load (meters -> feet for display when Imperial)
// - form submit (feet -> meters into the queued setFixedPosition admin msg)
// - browser geolocation fill (meters -> feet for display when Imperial)
//
// The only external seam we assert on is the admin message handed to the
// config editor's queueAdminMessage(), which is exactly what the firmware
// receives.

// --- Mutable per-test state (read lazily by the mock factories below) --------
// Names are prefixed with `mock` so vitest's vi.mock hoisting whitelist allows
// the factories to reference them.
let mockDisplayUnits: Protobuf.Config.Config_DisplayConfig_DisplayUnits;
let mockPositionConfig: Protobuf.Config.Config_PositionConfig;
let mockMyNode: Protobuf.Mesh.NodeInfo | undefined;

const mockSetRadioSection = vi.fn();
const mockQueueAdminMessage = vi.fn();
const mockToast = vi.fn();
const mockGetCurrentPosition = vi.fn();

// A minimal signal-shaped object; the mocked useSignal just returns `.value`.
const mockEditor = {
radio: { value: {}, peek: () => ({}), subscribe: () => () => {} },
setRadioSection: mockSetRadioSection,
queueAdminMessage: mockQueueAdminMessage,
};

const mockGetEffectiveConfig = (configCase: string) => {
if (configCase === "display") {
return { units: mockDisplayUnits };
}
if (configCase === "position") {
return mockPositionConfig;
}
return undefined;
};

// useWaitForConfig throws a suspense promise until config is present; the render
// path here supplies config directly, so it is a no-op in tests.
vi.mock("@app/core/hooks/useWaitForConfig", () => ({
useWaitForConfig: () => {},
}));

vi.mock("@core/stores", () => ({
useDevice: () => ({
config: { position: mockPositionConfig },
getEffectiveConfig: mockGetEffectiveConfig,
}),
}));

vi.mock("@meshtastic/sdk-react", () => ({
useConfigEditor: () => mockEditor,
useSignal: (signal: { value?: unknown }) => signal?.value ?? {},
}));

vi.mock("@core/hooks/useNodesAsProto.ts", () => ({
useMyNodeAsProto: () => mockMyNode,
}));

vi.mock("@core/hooks/useToast.ts", () => ({
useToast: () => ({ toast: mockToast }),
}));
Comment on lines +21 to +74

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail
rg -n '"vitest"|vi\.mock|vi\.hoisted|mockDisplayUnits|mockEditor' \
  --glob 'package.json' --glob '*lock*' \
  apps/web/src/components/PageComponents/Settings/Position.test.tsx

Repository: meshtastic/web

Length of output: 763


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '\n== vitest version ==\n'
rg -n '"vitest"' apps/web/package.json package.json pnpm-lock.yaml yarn.lock package-lock.json --max-count 5 || true

printf '\n== test file context ==\n'
sed -n '1,130p' apps/web/src/components/PageComponents/Settings/Position.test.tsx

Repository: meshtastic/web

Length of output: 5096


🌐 Web query:

Vitest vi.mock hoisted mock prefix documentation vi.hoisted factory variables

💡 Result:

In Vitest, the vi.mock function is hoisted to the top of the file, meaning it is executed before all other imports and code [1][2]. Because of this behavior, you cannot directly reference variables declared in the outer scope within the vi.mock factory function [1][3]. To work around this limitation and pass variables into a mocked module, you should use the vi.hoisted function [1][4]. vi.hoisted: This function allows you to define variables that are hoisted along with your vi.mock calls, making them available within the factory function [1][4]. - It must be defined before the vi.mock call [1]. - Both vi.hoisted and vi.mock will be hoisted in the order they are defined in the file [4]. Example usage: const { someVariable } = vi.hoisted( => { return { someVariable: 'initial-value' } }) vi.mock('./module-path', => { return { getValue: => someVariable } }) Unlike Jest, Vitest does not automatically allow variables to be used inside vi.mock based on specific naming prefixes (like starting with 'mock'). You must explicitly use vi.hoisted to share state between your test logic and the factory. If you do not require hoisting and need access to variables in the local scope, you can use vi.doMock, which is not hoisted, though it will only affect subsequent imports [1][3].

Citations:


Move the shared mock state into vi.hoisted.
vi.mock is hoisted, so these factories shouldn’t close over normal top-level let/const bindings. Use one vi.hoisted(() => ({ ... })) state object and reference mocks.* inside each factory.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/components/PageComponents/Settings/Position.test.tsx` around
lines 21 - 74, Move the shared mutable state and mock functions currently
declared as top-level let/const bindings into a single vi.hoisted state object.
Update the factories for useDevice, useConfigEditor, useMyNodeAsProto, and
useToast, along with mockGetEffectiveConfig and mockEditor, to reference the
corresponding mocks.* properties while preserving their existing behavior.

Source: MCP tools


// --- Helpers -----------------------------------------------------------------
const IMPERIAL = Protobuf.Config.Config_DisplayConfig_DisplayUnits.IMPERIAL;
const METRIC = Protobuf.Config.Config_DisplayConfig_DisplayUnits.METRIC;

// San Francisco, so the fixed-position submit path (lat/lng defined) is taken.
const LATITUDE_I = 377_749_000;
const LONGITUDE_I = -1_224_194_000;

interface SetupOptions {
units: Protobuf.Config.Config_DisplayConfig_DisplayUnits;
altitudeMeters: number;
}

function setup({ units, altitudeMeters }: SetupOptions) {
mockDisplayUnits = units;
// A full, valid PositionConfig (defaults for every schema field) with a
// fixed position enabled so the altitude field is editable and the submit
// takes the setFixedPosition path.
mockPositionConfig = create(Protobuf.Config.Config_PositionConfigSchema, {
fixedPosition: true,
});
mockMyNode = create(Protobuf.Mesh.NodeInfoSchema, {
num: 1,
position: create(Protobuf.Mesh.PositionSchema, {
latitudeI: LATITUDE_I,
longitudeI: LONGITUDE_I,
altitude: altitudeMeters,
}),
});
}

function renderPosition() {
return render(<Position onFormInit={() => {}} />);
}

function altitudeInput(container: HTMLElement): HTMLInputElement {
const input = container.querySelector<HTMLInputElement>("#altitude");
if (!input) {
throw new Error("altitude field not found");
}
return input;
}

function lastQueuedPosition(): Protobuf.Mesh.Position {
const message = mockQueueAdminMessage.mock.calls.at(
-1,
)?.[0] as Protobuf.Admin.AdminMessage;
expect(message.payloadVariant.case).toBe("setFixedPosition");
return message.payloadVariant.value as Protobuf.Mesh.Position;
}

describe("Position (altitude unit conversion, issue #1051)", () => {
beforeEach(() => {
vi.clearAllMocks();
});

afterEach(() => {
// Remove any geolocation override so the button does not leak into other
// tests (the component renders it only when navigator.geolocation exists).
if ("geolocation" in navigator) {
Reflect.deleteProperty(navigator, "geolocation");
}
});

it("Imperial: loads a 312 m altitude as ~1024 ft in the field", () => {
setup({ units: IMPERIAL, altitudeMeters: 312 });
const { container } = renderPosition();

// metersToFeet(312) = 1023.62 -> rounded 1024
expect(altitudeInput(container).value).toBe("1024");
// The field is labelled in Feet, not Meters.
expect(screen.getByText("Feet")).toBeInTheDocument();
expect(screen.queryByText("Meters")).not.toBeInTheDocument();
});

it("Imperial: submitting 1025 ft queues setFixedPosition with altitude 312 m", async () => {
setup({ units: IMPERIAL, altitudeMeters: 312 });
const { container } = renderPosition();

// DynamicForm submits on change (submitType defaults to "onChange").
fireEvent.change(altitudeInput(container), { target: { value: "1025" } });

await waitFor(() => expect(mockQueueAdminMessage).toHaveBeenCalled());

const position = lastQueuedPosition();
// feetToMeters(1025) = 312.42 -> rounded 312 (int32 meters), NOT 1025.
expect(position.altitude).toBe(312);
// Lat/lng round-trip untouched (still integer degrees * 1e7).
expect(position.latitudeI).toBe(LATITUDE_I);
expect(position.longitudeI).toBe(LONGITUDE_I);
});

it("Metric: submitting 312 queues setFixedPosition with altitude 312 m unchanged", async () => {
// Start from a different metric altitude so the change event fires.
setup({ units: METRIC, altitudeMeters: 100 });
const { container } = renderPosition();

// Metric field shows raw meters, no conversion.
expect(altitudeInput(container).value).toBe("100");
expect(screen.getByText("Meters")).toBeInTheDocument();

fireEvent.change(altitudeInput(container), { target: { value: "312" } });

await waitFor(() => expect(mockQueueAdminMessage).toHaveBeenCalled());

expect(lastQueuedPosition().altitude).toBe(312);
});

it("Imperial: browser geolocation fills the field in feet (100 m -> 328 ft)", async () => {
setup({ units: IMPERIAL, altitudeMeters: 312 });

// Geolocation reports meters; component must convert to feet for display.
mockGetCurrentPosition.mockImplementation((success: PositionCallback) =>
success({
coords: {
latitude: 37.7749,
longitude: -122.4194,
altitude: 100,
accuracy: 5,
altitudeAccuracy: null,
heading: null,
speed: null,
},
} as GeolocationPosition),
);
Object.defineProperty(navigator, "geolocation", {
configurable: true,
value: { getCurrentPosition: mockGetCurrentPosition },
});

const { container } = renderPosition();

// Sanity: starts at the loaded 312 m -> 1024 ft.
expect(altitudeInput(container).value).toBe("1024");

fireEvent.click(
screen.getByRole("button", { name: /use browser location/i }),
);

// metersToFeet(100) = 328.08 -> rounded 328
await waitFor(() => expect(altitudeInput(container).value).toBe("328"));
expect(mockGetCurrentPosition).toHaveBeenCalledTimes(1);
});

it("clearing the altitude field does not queue a setFixedPosition", async () => {
// Regression coverage requested by CodeRabbit. Clearing a number field
// yields NaN (FormInput does Number.parseFloat("").toString() === "NaN"),
// which fails altitude's `z.coerce.number().optional()` validation. Because
// DynamicForm submits via handleSubmit(onSubmit) on change, an invalid form
// never calls onSubmit, so nothing is queued. The `Number.isFinite(...) ? ...
// : 0` guard in onSubmit is thus defensive and unreachable through the form.
setup({ units: METRIC, altitudeMeters: 100 });
const { container } = renderPosition();

// Baseline: a valid edit queues exactly one setFixedPosition, proving the
// form is wired up and submits on change.
fireEvent.change(altitudeInput(container), { target: { value: "150" } });
await waitFor(() => expect(mockQueueAdminMessage).toHaveBeenCalledTimes(1));
expect(lastQueuedPosition().altitude).toBe(150);

// Clear the field -> NaN -> invalid -> must NOT queue anything.
fireEvent.change(altitudeInput(container), { target: { value: "" } });

// Recover with another valid edit and synchronize on its value landing.
// Then assert exactly two queues total (150 then 200): if the clear had
// queued, the count would be three. Synchronizing on the settled value
// (not a bare negative assertion) keeps this non-racy.
fireEvent.change(altitudeInput(container), { target: { value: "200" } });
await waitFor(() => expect(lastQueuedPosition().altitude).toBe(200));
expect(mockQueueAdminMessage).toHaveBeenCalledTimes(2);
});
});
55 changes: 37 additions & 18 deletions apps/web/src/components/PageComponents/Settings/Position.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
import { useMyNodeAsProto } from "@core/hooks/useNodesAsProto.ts";
import { useToast } from "@core/hooks/useToast.ts";
import { useDevice } from "@core/stores";
import { feetToMeters, metersToFeet } from "@core/utils/geo.ts";
import { Protobuf } from "@meshtastic/sdk";
import { useConfigEditor, useSignal } from "@meshtastic/sdk-react";
import { LocateFixed } from "lucide-react";
Expand All @@ -32,7 +33,7 @@ interface PositionConfigProps {
* via navigator.geolocation and writes lat/lng/altitude into the form.
* No-op without a geolocation API (e.g. insecure context).
*/
function UseBrowserLocationButton() {
function UseBrowserLocationButton({ isImperial }: { isImperial: boolean }) {
const { setValue } = useFormContext<PositionValidation>();
const { toast } = useToast();
const { t } = useTranslation("config");
Expand All @@ -57,9 +58,14 @@ function UseBrowserLocationButton() {
pos.coords.altitude !== null &&
!Number.isNaN(pos.coords.altitude)
) {
setValue("altitude", Math.round(pos.coords.altitude), {
shouldDirty: true,
});
// Geolocation altitude is in meters; show feet when Imperial.
setValue(
"altitude",
isImperial
? Math.round(metersToFeet(pos.coords.altitude))
: Math.round(pos.coords.altitude),
{ shouldDirty: true },
);
}
},
(err) => {
Expand Down Expand Up @@ -113,8 +119,12 @@ export const Position = ({ onFormInit }: PositionConfigProps) => {

const currentPosition = myNode?.position;
const displayUnits = getEffectiveConfig("display")?.units;
const isImperial =
displayUnits === Protobuf.Config.Config_DisplayConfig_DisplayUnits.IMPERIAL;

const formValues = useMemo(() => {
// Firmware stores altitude in meters; the field shows feet when Imperial.
const altitudeMeters = currentPosition?.altitude ?? 0;
return {
...config.position,
...effectivePosition,
Expand All @@ -124,9 +134,11 @@ export const Position = ({ onFormInit }: PositionConfigProps) => {
longitude: currentPosition?.longitudeI
? currentPosition.longitudeI / 1e7
: undefined,
altitude: currentPosition?.altitude ?? 0,
altitude: isImperial
? Math.round(metersToFeet(altitudeMeters))
: altitudeMeters,
} as PositionValidation;
}, [config.position, effectivePosition, currentPosition]);
}, [config.position, effectivePosition, currentPosition, isImperial]);

const onSubmit = (data: PositionValidation) => {
const {
Expand All @@ -149,13 +161,24 @@ export const Position = ({ onFormInit }: PositionConfigProps) => {
data.latitude !== undefined &&
data.longitude !== undefined
) {
// The altitude field is in feet when Imperial; the protobuf expects
// integer meters, so convert back and round. Guard against a
// cleared/NaN field.
const altitudeInput =
typeof data.altitude === "number" && Number.isFinite(data.altitude)
? data.altitude
: 0;
const altitude = isImperial
? Math.round(feetToMeters(altitudeInput))
: Math.round(altitudeInput);

const message = create(Protobuf.Admin.AdminMessageSchema, {
payloadVariant: {
case: "setFixedPosition",
value: create(Protobuf.Mesh.PositionSchema, {
latitudeI: Math.round(data.latitude * 1e7),
longitudeI: Math.round(data.longitude * 1e7),
altitude: data.altitude || 0,
altitude,
time: Math.floor(Date.now() / 1000),
}),
},
Expand Down Expand Up @@ -220,7 +243,7 @@ export const Position = ({ onFormInit }: PositionConfigProps) => {
{
label: t("position.deviceGps.label"),
description: t("position.deviceGps.description"),
footer: <UseBrowserLocationButton />,
footer: <UseBrowserLocationButton isImperial={isImperial} />,
fields: [
{
type: "toggle",
Expand Down Expand Up @@ -257,19 +280,15 @@ export const Position = ({ onFormInit }: PositionConfigProps) => {
name: "altitude",
label: t("position.fixedPosition.altitude.label"),
description: t("position.fixedPosition.altitude.description", {
unit:
displayUnits ===
Protobuf.Config.Config_DisplayConfig_DisplayUnits.IMPERIAL
? "Feet"
: "Meters",
unit: isImperial
? t("unit.foot.plural")
: t("unit.meter.plural"),
}),
properties: {
step: 0.0000001,
suffix:
displayUnits ===
Protobuf.Config.Config_DisplayConfig_DisplayUnits.IMPERIAL
? "Feet"
: "Meters",
suffix: isImperial
? t("unit.foot.plural")
: t("unit.meter.plural"),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this always be the plural version?

},
disabledBy: [{ fieldName: "fixedPosition" }],
},
Expand Down
Loading
Loading