diff --git a/apps/web/src/components/Form/FormInput.test.ts b/apps/web/src/components/Form/FormInput.test.ts new file mode 100644 index 000000000..20820ee8a --- /dev/null +++ b/apps/web/src/components/Form/FormInput.test.ts @@ -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); + }, + ); +}); diff --git a/apps/web/src/components/Form/FormInput.tsx b/apps/web/src/components/Form/FormInput.tsx index 487f96569..c85a02b22 100644 --- a/apps/web/src/components/Form/FormInput.tsx +++ b/apps/web/src/components/Form/FormInput.tsx @@ -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 extends BaseFormBuilderProps { type: "text" | "number" | "password"; inputChange?: ChangeEventHandler; @@ -59,11 +72,12 @@ export function GenericInput({ 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 diff --git a/apps/web/src/components/PageComponents/Settings/Position.tsx b/apps/web/src/components/PageComponents/Settings/Position.tsx index c61674695..4483b2620 100644 --- a/apps/web/src/components/PageComponents/Settings/Position.tsx +++ b/apps/web/src/components/PageComponents/Settings/Position.tsx @@ -236,7 +236,7 @@ export const Position = ({ onFormInit }: PositionConfigProps) => { properties: { step: 0.0000001, suffix: "Degrees", - fieldLength: { max: 10 }, + fieldLength: { max: 11 }, }, disabledBy: [{ fieldName: "fixedPosition" }], }, @@ -248,7 +248,7 @@ export const Position = ({ onFormInit }: PositionConfigProps) => { properties: { step: 0.0000001, suffix: "Degrees", - fieldLength: { max: 10 }, + fieldLength: { max: 12 }, }, disabledBy: [{ fieldName: "fixedPosition" }], }, diff --git a/apps/web/src/validation/config/position.test.ts b/apps/web/src/validation/config/position.test.ts new file mode 100644 index 000000000..d2248b5b3 --- /dev/null +++ b/apps/web/src/validation/config/position.test.ts @@ -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(); + } + }); +}); diff --git a/apps/web/src/validation/config/position.ts b/apps/web/src/validation/config/position.ts index 33419b60f..22d699e5a 100644 --- a/apps/web/src/validation/config/position.ts +++ b/apps/web/src/validation/config/position.ts @@ -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); + const decimals = mantissa.split(".")[1]?.length ?? 0; + return decimals - Number(exponent) <= places; +}; + +// 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(), @@ -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(), });