diff --git a/package.json b/package.json index a669b6a..9418038 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,8 @@ "prepack": "npm run build", "prepublishOnly": "npm run check && npm run typecheck && npm run pack:check && npm run types:check", "types:check": "attw --pack . --profile esm-only --no-summary", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "test": "npm run build && node --test tests/*.test.mjs" }, "devDependencies": { "@arethetypeswrong/cli": "^0.18.3", diff --git a/src/admin.tsx b/src/admin.tsx index 84077c1..01a837a 100644 --- a/src/admin.tsx +++ b/src/admin.tsx @@ -227,13 +227,33 @@ function renderChoiceCardLabel(choice: FieldsChoice) { ); } -function readInputValue(event: ChangeEvent) { +export function parseNumericInput(value: string, type: "number" | "integer") { + if (value.trim() === "") { + return undefined; + } + + const numericValue = Number(value); + if (!Number.isFinite(numericValue)) { + return undefined; + } + + if (type === "integer" && !Number.isInteger(numericValue)) { + return undefined; + } + + return numericValue; +} + +function readInputValue( + event: ChangeEvent, + type: FieldsSubField["type"], +) { const target = event.currentTarget; if (target instanceof HTMLInputElement && target.type === "checkbox") { return target.checked; } if (target instanceof HTMLInputElement && target.type === "number") { - return target.value === "" ? undefined : Number(target.value); + return parseNumericInput(target.value, type === "integer" ? "integer" : "number"); } return target.value; } @@ -253,7 +273,7 @@ function renderSubField( placeholder: field.placeholder, value: typeof value === "string" || typeof value === "number" ? value : "", onChange: (event: ChangeEvent) => - onChange(readInputValue(event)), + onChange(readInputValue(event, type)), }; const selectChoices = choices(field.options); diff --git a/tests/numeric-input.test.mjs b/tests/numeric-input.test.mjs new file mode 100644 index 0000000..df4d5d9 --- /dev/null +++ b/tests/numeric-input.test.mjs @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { parseNumericInput } from "../dist/admin.mjs"; + +test("numeric input emits undefined for empty values", () => { + assert.equal(parseNumericInput("", "number"), undefined); + assert.equal(parseNumericInput(" ", "integer"), undefined); +}); + +test("numeric input emits undefined for invalid values instead of NaN", () => { + assert.equal(parseNumericInput("not-a-number", "number"), undefined); + assert.equal(parseNumericInput("123abc", "integer"), undefined); + assert.equal(parseNumericInput("Infinity", "number"), undefined); +}); + +test("number input accepts integer, decimal, and boundary finite values", () => { + assert.equal(parseNumericInput("42", "number"), 42); + assert.equal(parseNumericInput("3.14", "number"), 3.14); + assert.equal(parseNumericInput(String(Number.MAX_SAFE_INTEGER), "number"), Number.MAX_SAFE_INTEGER); + assert.equal(parseNumericInput(String(Number.MIN_SAFE_INTEGER), "number"), Number.MIN_SAFE_INTEGER); +}); + +test("integer input accepts integers and rejects decimals", () => { + assert.equal(parseNumericInput("42", "integer"), 42); + assert.equal(parseNumericInput("-9007199254740991", "integer"), Number.MIN_SAFE_INTEGER); + assert.equal(parseNumericInput("3.14", "integer"), undefined); +});