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
30 changes: 30 additions & 0 deletions apps/web/src/components/Form/FormInput.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { normalizeNumberInput } from "./FormInput.tsx";

describe("normalizeNumberInput", () => {
it.each([
["", ""],
["-", "-"],
["34.", "34."],
["-34.", "-34."],
])("preserves intermediate value %j while typing", (input, expected) => {
expect(normalizeNumberInput(input)).toBe(expected);
});

it.each([
["-34.1147648", "-34.1147648"],
["34.1147648", "34.1147648"],
["0", "0"],
["-180", "-180"],
["1e5", "100000"],
])("normalizes complete number %j to %j", (input, expected) => {
expect(normalizeNumberInput(input)).toBe(expected);
});

it.each([["1e"], ["1e-"], ["12abc"], ["1.2.3"], ["Infinity"], ["-Infinity"]])(
"keeps invalid text %j instead of truncating it",
(input) => {
expect(normalizeNumberInput(input)).toBe(input);
},
);
});
24 changes: 19 additions & 5 deletions apps/web/src/components/Form/FormInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ import { Input } from "@components/UI/Input.tsx";
import type { ChangeEventHandler } from "react";
import { type FieldValues, useController } from "react-hook-form";

export function normalizeNumberInput(value: string): string {
// Preserve intermediate values while typing negative numbers or decimals.
if (value === "" || value === "-" || value.endsWith(".")) {
return value;
}

// Only normalize when the complete string is a finite number, so partial or
// invalid entries ("1e", "12abc", "1.2.3", "Infinity") are kept as typed and
// surfaced by validation instead of being silently truncated.
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed.toString() : value;
}

export interface InputFieldProps<T> extends BaseFormBuilderProps<T> {
type: "text" | "number" | "password";
inputChange?: ChangeEventHandler;
Expand Down Expand Up @@ -59,11 +72,12 @@ export function GenericInput<T extends FieldValues>({
field.inputChange(e);
}

controllerField.onChange(
field.type === "number"
? Number.parseFloat(newValue).toString()
: newValue,
);
if (field.type !== "number") {
controllerField.onChange(newValue);
return;
}

controllerField.onChange(normalizeNumberInput(newValue));
};

const currentLength = controllerField.value
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/PageComponents/Settings/Position.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,7 @@ export const Position = ({ onFormInit }: PositionConfigProps) => {
properties: {
step: 0.0000001,
suffix: "Degrees",
fieldLength: { max: 10 },
fieldLength: { max: 11 },
},
disabledBy: [{ fieldName: "fixedPosition" }],
},
Expand All @@ -248,7 +248,7 @@ export const Position = ({ onFormInit }: PositionConfigProps) => {
properties: {
step: 0.0000001,
suffix: "Degrees",
fieldLength: { max: 10 },
fieldLength: { max: 12 },
},
disabledBy: [{ fieldName: "fixedPosition" }],
},
Expand Down
107 changes: 107 additions & 0 deletions apps/web/src/validation/config/position.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { describe, expect, it } from "vitest";
import { PositionValidationSchema } from "./position.ts";

const validBase = {
positionBroadcastSecs: 0,
positionBroadcastSmartEnabled: false,
fixedPosition: false,
gpsUpdateInterval: 0,
positionFlags: 0,
rxGpio: 0,
txGpio: 0,
broadcastSmartMinimumDistance: 0,
broadcastSmartMinimumIntervalSecs: 0,
gpsEnGpio: 0,
gpsMode: 0,
};

describe("PositionValidationSchema", () => {
it("accepts positive latitude and longitude with 7 decimal places", () => {
const result = PositionValidationSchema.safeParse({
...validBase,
latitude: 34.1147648,
longitude: 28.3166667,
});
expect(result.success).toBe(true);
});

it("accepts negative latitude and longitude with 7 decimal places", () => {
const result = PositionValidationSchema.safeParse({
...validBase,
latitude: -34.1147648,
longitude: -122.4194165,
});
expect(result.success).toBe(true);
});

it("rejects latitude with more than 7 decimal places", () => {
const result = PositionValidationSchema.safeParse({
...validBase,
latitude: -34.11476481,
});
expect(result.success).toBe(false);
});

it("rejects longitude with more than 7 decimal places", () => {
const result = PositionValidationSchema.safeParse({
...validBase,
longitude: -122.41941654,
});
expect(result.success).toBe(false);
});

it("rejects latitude with more than 7 decimal places in exponential notation", () => {
const result = PositionValidationSchema.safeParse({
...validBase,
latitude: 1.2e-7,
});
expect(result.success).toBe(false);
});

it("accepts latitude with exactly 7 decimal places in exponential notation", () => {
const result = PositionValidationSchema.safeParse({
...validBase,
latitude: 1e-7,
});
expect(result.success).toBe(true);
});

it.each([
["latitude", 91],
["latitude", -91],
["longitude", 181],
["longitude", -181],
])("rejects %s outside the valid range (%d)", (field, value) => {
const result = PositionValidationSchema.safeParse({
...validBase,
[field]: value,
});
expect(result.success).toBe(false);
});

it.each([
["latitude", 90],
["latitude", -90],
["longitude", 180],
["longitude", -180],
])("accepts %s at the inclusive boundary (%d)", (field, value) => {
const result = PositionValidationSchema.safeParse({
...validBase,
[field]: value,
});
expect(result.success).toBe(true);
});

it("treats empty string coordinates as undefined instead of 0", () => {
const result = PositionValidationSchema.safeParse({
...validBase,
latitude: "",
longitude: "",
});
expect(result.success).toBe(true);
if (result.success) {
expect(result.data.latitude).toBeUndefined();
expect(result.data.longitude).toBeUndefined();
}
});
});
33 changes: 31 additions & 2 deletions apps/web/src/validation/config/position.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,19 @@ import { z } from "zod/v4";

const GpsModeEnum = z.enum(Protobuf.Config.Config_PositionConfig_GpsMode);

const maxDecimalPlaces = (places: number) => (value: number | undefined) => {
if (value === undefined) return true;
// Account for exponential notation, e.g. 1.2e-7 has 8 decimal places.
const [mantissa = "", exponent = "0"] = value.toString().split(/e/i);

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.

Sorry I didn't catch this before, what is a mantissa?

const decimals = mantissa.split(".")[1]?.length ?? 0;
return decimals - Number(exponent) <= places;
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Coerce cleared inputs to undefined so optional coordinates are omitted
// instead of being coerced to 0.
const emptyStringToUndefined = (value: unknown) =>
typeof value === "string" && value.trim() === "" ? undefined : value;

export const PositionValidationSchema = z.object({
positionBroadcastSecs: z.coerce.number().int().min(0),
positionBroadcastSmartEnabled: z.boolean(),
Expand All @@ -15,8 +28,24 @@ export const PositionValidationSchema = z.object({
broadcastSmartMinimumIntervalSecs: z.coerce.number().int().min(0),
gpsEnGpio: z.coerce.number().int().min(0),
gpsMode: GpsModeEnum,
latitude: z.coerce.number().min(-90).max(90).optional(),
longitude: z.coerce.number().min(-180).max(180).optional(),
latitude: z.preprocess(
emptyStringToUndefined,
z.coerce
.number()
.min(-90)
.max(90)
.optional()
.refine(maxDecimalPlaces(7), { message: "Max 7 decimal precision" }),
),
longitude: z.preprocess(
emptyStringToUndefined,
z.coerce
.number()
.min(-180)
.max(180)
.optional()
.refine(maxDecimalPlaces(7), { message: "Max 7 decimal precision" }),
),
altitude: z.coerce.number().optional(),
});

Expand Down
Loading