From 70ea4d51127978b885a57396e54ce12d8f3f2e52 Mon Sep 17 00:00:00 2001 From: Nathan Farmer Date: Wed, 6 Mar 2024 09:11:43 -0500 Subject: [PATCH] feat: Add `NumberControl` and `NumericControl` tests (#17) * Port over existing code * Fix typing errors * Exclude .ts files from prodution build * Exclude all files with .test. from prod build * Add untracked file * Remove unused file * Move tests to another branch * Push trivial change * Revert omit change * Remove NumericControlProps and use cast * Add existing tests * Data can be something other than Record * Update to new registry pattern * Remove unused schemas * Deleted stories from other branch * Fix test * Pass min, max, step, and controls to InputNumber also * Only show percentage addonAfter if isPercentage * Only show percentage addonAfter if isPercentage * Fix marginLeft on pure number slider * Update tooltip logic for non-percentages on slider * Update logic to fill functionality gaps * Add logic for required asterisk * Fix tooltips * Use new schemas in tests * Update tests to use new schemas * Update value default logic * Revert changes to FormStateWrapper * Revert changes to test-render --- src/controls/NumberControl.test.tsx | 102 +++++++++++++ src/controls/NumericControl.test.tsx | 55 +++++++ src/controls/utils.test.ts | 32 ++++ src/testSchemas/numberSchema.ts | 219 +++++++++++++++++++++++++++ tsconfig.production.json | 2 +- 5 files changed, 409 insertions(+), 1 deletion(-) create mode 100644 src/controls/NumberControl.test.tsx create mode 100644 src/controls/NumericControl.test.tsx create mode 100644 src/controls/utils.test.ts create mode 100644 src/testSchemas/numberSchema.ts diff --git a/src/controls/NumberControl.test.tsx b/src/controls/NumberControl.test.tsx new file mode 100644 index 0000000..7987566 --- /dev/null +++ b/src/controls/NumberControl.test.tsx @@ -0,0 +1,102 @@ +import { describe, expect, test, it } from "vitest" +import { screen, waitFor } from "@testing-library/react" +import { userEvent } from "@testing-library/user-event" +import { render } from "../common/test-render" +import { JSONSchema } from "json-schema-to-ts" +import { + numberBasisPointsSchema, + numberBasisPointsUISchema, + numberMagnitudeSchema, + numberMagnitudeUISchema, + numberTheNumberSchema, + numberTheNumberUISchema, + numberUISchemaWithRule, +} from "../testSchemas/numberSchema" + +describe("NumberControl", () => { + test("renders a number input with no UISchema provided", () => { + render({ + schema: numberMagnitudeSchema, + }) + + expect(screen.getByText("Magnitude")).not.toBeNull() + }) + + it("Follows the hide rule", () => { + const data = { magnitude: 1000 } + render({ + data: data, + schema: numberMagnitudeSchema, + uischema: numberUISchemaWithRule, + }) + expect(screen.queryByText("Magnitude")).toBeNull() + }) + + it.each([[0], [100]])("renders when data of %s is included", (dataVal: number) => { + const data = { theNumber: dataVal} + render({ + data: data, + schema: numberTheNumberSchema, // this has a default of 42.42 + uischema: numberTheNumberUISchema, + }) + expect(screen.getByText("The Number")).not.toBeNull() + expect(screen.getByRole("spinbutton")).toHaveValue(`${dataVal}`) + }) + + it.each([[0], [100]])("renders default value of %s when no data is provided", (defaultValue: number) => { + const { properties, ...rest } = numberMagnitudeSchema + properties.magnitude = { ...properties.magnitude, ...{ default: defaultValue }} + render({ + schema: { ...rest, properties }, + uischema: numberMagnitudeUISchema, + }) + + expect(screen.getByText("Magnitude")).not.toBeNull() + expect(screen.getByRole("spinbutton")).toHaveValue(`${defaultValue}`) + }) + it("renders default value when no data is provided", () => { + render({ + schema: numberTheNumberSchema, + uischema: numberTheNumberUISchema, + }) + expect(screen.getByRole("spinbutton")).toHaveValue("42.42") + }) + + it("changes its value when users type", async () => { + let data: JSONSchema + render({ + schema: numberMagnitudeSchema, + uischema: numberMagnitudeUISchema, + onChange: (state: { data: JSONSchema }) => { + data = state.data + }, + }) + + await userEvent.clear(screen.getByRole("spinbutton")) + await userEvent.type(screen.getByRole("spinbutton"), "123") + + await waitFor(() => { + expect(data).toEqual({ magnitude: 123 }) + }) + }) + + it("renders slider when min max values are present", () => { + const data = { basisPoints: 1 } + render({ + data: data, + schema: numberBasisPointsSchema, + uischema: numberBasisPointsUISchema, + }) + expect(screen.getByText("Basis Points")).not.toBeNull() + expect(screen.getByRole("spinbutton")).toHaveValue("1") + expect(screen.getByRole("slider")).not.toBeNull() + expect(screen.getByRole("slider")).toHaveAttribute("aria-valuenow", "1") + }) + it("hides slider when min max values are not present", () => { + render({ + schema: numberMagnitudeSchema, + uischema: numberMagnitudeUISchema, + }) + expect(screen.queryByRole("slider")).toBeNull() + }) +}) \ No newline at end of file diff --git a/src/controls/NumericControl.test.tsx b/src/controls/NumericControl.test.tsx new file mode 100644 index 0000000..31222b9 --- /dev/null +++ b/src/controls/NumericControl.test.tsx @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest" +import { screen, waitFor } from "@testing-library/react" +import { userEvent } from "@testing-library/user-event" +import { render } from "../common/test-render" +import { numberTheNumberSchema, numberWeightSchema } from "../testSchemas/numberSchema" + +describe("NumericControl", () => { + it("does not fall back to default if value is empty", () => { + render({ + schema: { ...numberTheNumberSchema }, + data: {}, + }) + + expect(screen.getByRole("spinbutton")).toHaveValue("") + }) + + it("calls onChange with number values", async () => { + let data = { theNumber: 42.00 } + render({ + schema: numberTheNumberSchema, + data, + onChange: (state) => { + data = state.data + }, + }) + + await userEvent.clear(screen.getByRole("spinbutton")) + await userEvent.type(screen.getByRole("spinbutton"), "42.00") + + await waitFor(() => { + expect(data).toBe(42.00) + }) + }) + + it("calls onChange with empty object and no errors when the input gets cleared out and optional", async () => { + const weight = {} + let state: Record = {} + render({ + schema: numberWeightSchema, + data: weight, + onChange: (newState) => { + state = newState + }, + }) + + await userEvent.clear(screen.getByRole("spinbutton")) + + await waitFor(() => { + expect(state.data).toBe(weight) + const errors = state.errors + console.log({errors}) + expect(state.errors).toHaveLength(0) + }) + }) +}) \ No newline at end of file diff --git a/src/controls/utils.test.ts b/src/controls/utils.test.ts new file mode 100644 index 0000000..58b9e88 --- /dev/null +++ b/src/controls/utils.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "vitest" +import { decimalToPercentage, percentageStringToDecimal } from "./utils" + +describe("percentageStringToDecimal", () => { + test.each([ + { value: "100", expected: 1 }, + { value: "0", expected: 0 }, + { value: "50", expected: 0.5 }, + { value: "0.5", expected: 0.005 }, + { value: "0.005", expected: 0.00005 }, + { value: "0.066", expected: 0.00066 }, + { value: "85.9999999999", expected: 0.859999999999 }, + ])("when value is $value it returns $expected", ({ value, expected }: { value: string; expected: number }) => { + expect(percentageStringToDecimal(value)).toEqual(expected) + }) +}) + +describe("decimalToPercentage", () => { + test.each([ + { value: 1, expected: "100" }, + { value: 0, expected: "0" }, + { value: 0.5, expected: "50" }, + { value: 0.005, expected: "0.5" }, + { value: 0.00005, expected: "0.005" }, + { value: 0.859999999999, expected: "85.9999999999" }, + ])( + "when value is $value it returns $expected. If symbol is provided, it is appended to the value", + ({ value, expected }: { value: number; expected: string }) => { + expect(decimalToPercentage(value)).toEqual(expected) + }, + ) +}) \ No newline at end of file diff --git a/src/testSchemas/numberSchema.ts b/src/testSchemas/numberSchema.ts new file mode 100644 index 0000000..6804573 --- /dev/null +++ b/src/testSchemas/numberSchema.ts @@ -0,0 +1,219 @@ +import { RuleEffect } from "@jsonforms/core" +import { JSONSchema } from "json-schema-to-ts" +import { UISchema } from "../ui-schema" + +export const numberMagnitudeSchema = { + type: "object", + properties: { + magnitude: { + title: "Magnitude", + type: "number", + }, + }, + required: ["magnitude"], +} satisfies JSONSchema + +export const numberTheNumberSchema = { + type: "number", + properties: { + theNumber: { + title: "The Number", + type: "number", + default: 42.42, + }, + }, + required: ["theNumber"], +} satisfies JSONSchema + +export const numberWeightSchema = { + type: "object", + properties: { + weight: { + title: "Weight", + type: "number", + }, + }, +} satisfies JSONSchema + +export const numberPriceSchema = { + type: "object", + properties: { + price: { + title: "Price", + type: "number", + }, + }, + required: ["price"], +} satisfies JSONSchema + +export const numberBasisPointsSchema = { + type: "object", + properties: { + basisPoints: { + title: "Basis Points", + type: "number", + minimum: 0, + maximum: 10000, + multipleOf: 1, + }, + }, +} satisfies JSONSchema + +export const numberTemperatureSchema = { + type: "number", + title: "Today's Temperature", + minimum: -50, + maximum: 150, + default: 70, + multipleOf: 1, +} satisfies JSONSchema + +export const numberHumiditySchema = { + type: "number", + title: "Humidity", + minimum: 0.0, + maximum: 1.0, +} satisfies JSONSchema + +export const numberRelativeChangeSchema = { + type: "number", + title: "Relative Change", + minimum: -1.0, + maximum: 10.0, + default: 0.0, + multipleOf: 0.01, +} satisfies JSONSchema + +export const numberFinalGradeSchema = { + type: "number", + title: "Final Grade", + minimum: 0.0, + maximum: 1.0, + default: 0.5, +} satisfies JSONSchema + + +export const numberDonateNowSchema = { + type: "object", + properties: { + donateNow: { + type: "number", + title: "Donate Now", + minimum: 5.00, + maximum: 1000.00, + default: 20.00, + multipleOf: 5.00, + } + }, + required: ["donateNow"], +} satisfies JSONSchema + + +export const numberMagnitudeUISchema = { + type: "VerticalLayout", + elements: [ + { + type: "Control", + scope: "#/properties/magnitude", + }, + ], +} satisfies UISchema + +export const numberPriceUISchema = { + type: "VerticalLayout", + elements: [ + { + type: "Control", + scope: "#/properties/price", + options: { + addonBefore: "$", + } + }, + ], +} satisfies UISchema + +export const numberTheNumberUISchema = { + type: "VerticalLayout", + elements: [ + { + type: "Control", + scope: "#/properties/theNumber", + }, + ], +} satisfies UISchema + + +export const numberWeightUISchema = { + type: "VerticalLayout", + elements: [ + { + type: "Control", + scope: "#/properties/weight", + }, + ], +} satisfies UISchema + + +export const numberBasisPointsUISchema = { + type: "VerticalLayout", + elements: [ + { + type: "Control", + scope: "#/properties/basisPoints", + }, + ], +} satisfies UISchema + + +export const numberPercentageUISchema = { + type: "VerticalLayout", + elements: [ + { + type: "Control", + scope: "#", + options: { + addonAfter: "%", + } + }, + ], +} satisfies UISchema + +export const numberTemperatureUISchema = { + type: "VerticalLayout", + elements: [ + { + type: "Control", + scope: "#", + options: { + addonAfter: "°F", + } + }, + ], +} satisfies UISchema + +export const numberDonateNowUISchema = { + type: "VerticalLayout", + elements: [ + { + type: "Control", + scope: "#/properties/donateNow", + options: { + addonBefore: "$", + } + }, + ], +} satisfies UISchema + +export const numberUISchemaWithRule = { + type: "VerticalLayout", + elements: [ + { + type: "Control", + scope: "#", + rule: { + effect: RuleEffect.HIDE, + condition: {}, + }, + }, + ], +} satisfies UISchema \ No newline at end of file diff --git a/tsconfig.production.json b/tsconfig.production.json index 0c2cbf7..687039a 100644 --- a/tsconfig.production.json +++ b/tsconfig.production.json @@ -3,7 +3,7 @@ "src" ], "exclude": [ - "**/*.test.tsx", + "**/*.test.*", "**/*.stories.*", "src/common/test-render.tsx" ],