From 245137e5a830e58a30f172ce3cd8b5cf37f9fabd Mon Sep 17 00:00:00 2001 From: EndiUN Date: Tue, 28 Apr 2026 23:45:30 +0200 Subject: [PATCH 1/4] Additional UI/UX test for minitool 1 component --- .../__tests__/BarGenerationModal.test.jsx | 168 ++++++++++++++++++ .../__tests__/BarInfoModal.test.jsx | 67 +++++++ .../__tests__/BatteryBar.test.jsx | 112 ++++++++++++ .../__tests__/ChartControls.test.jsx | 106 +++++++++++ .../__tests__/DataGenerationModal.test.jsx | 168 ++++++++++++++++++ .../__tests__/RangeTool.test.jsx | 78 ++++++++ .../__tests__/ValueTool.test.jsx | 68 +++++++ 7 files changed, 767 insertions(+) create mode 100644 app/(Minitool_one)/__tests__/BarGenerationModal.test.jsx create mode 100644 app/(Minitool_one)/__tests__/BarInfoModal.test.jsx create mode 100644 app/(Minitool_one)/__tests__/BatteryBar.test.jsx create mode 100644 app/(Minitool_one)/__tests__/ChartControls.test.jsx create mode 100644 app/(Minitool_one)/__tests__/DataGenerationModal.test.jsx create mode 100644 app/(Minitool_one)/__tests__/RangeTool.test.jsx create mode 100644 app/(Minitool_one)/__tests__/ValueTool.test.jsx diff --git a/app/(Minitool_one)/__tests__/BarGenerationModal.test.jsx b/app/(Minitool_one)/__tests__/BarGenerationModal.test.jsx new file mode 100644 index 0000000..e80a0ef --- /dev/null +++ b/app/(Minitool_one)/__tests__/BarGenerationModal.test.jsx @@ -0,0 +1,168 @@ +import React from "react"; +import { render, fireEvent, screen, act } from "@testing-library/react-native"; +import { Alert } from "react-native"; +import useBarGenerationModal from "../modals/BarGenerationModal"; + +// Tiny harness: hooks can't be rendered directly, so wrap and expose handlers. +const Harness = React.forwardRef((props, ref) => { + const modal = useBarGenerationModal(props); + React.useImperativeHandle(ref, () => modal, [modal]); + return modal.renderModal(); +}); + +describe("BarGenerationModal (useBarGenerationModal)", () => { + let alertSpy; + + beforeEach(() => { + jest.clearAllMocks(); + alertSpy = jest.spyOn(Alert, "alert").mockImplementation(() => {}); + }); + + afterEach(() => { + alertSpy.mockRestore(); + }); + + describe("Visibility", () => { + test("modal is hidden by default", () => { + render(); + expect(screen.queryByText("Add a New Battery")).toBeNull(); + }); + + test("handleOpenModal makes the modal visible", () => { + const ref = React.createRef(); + render(); + act(() => ref.current.handleOpenModal()); + expect(screen.getByText("Add a New Battery")).toBeTruthy(); + }); + + test("does not open when bar count is at the limit, alerts the user", () => { + const ref = React.createRef(); + render( + , + ); + act(() => ref.current.handleOpenModal()); + expect(screen.queryByText("Add a New Battery")).toBeNull(); + expect(alertSpy).toHaveBeenCalledWith( + "Limit Reached", + expect.stringContaining("more than 20 batteries"), + ); + }); + }); + + describe("Form interaction", () => { + test("renders inputs, brand selector and action buttons", () => { + const ref = React.createRef(); + render(); + act(() => ref.current.handleOpenModal()); + expect(screen.getByText("Lifespan (1-130):")).toBeTruthy(); + expect(screen.getByText("Brand:")).toBeTruthy(); + expect(screen.getByText("Tough Cell")).toBeTruthy(); + expect(screen.getByText("Always Ready")).toBeTruthy(); + expect(screen.getByText("Cancel")).toBeTruthy(); + expect(screen.getByText("Add Bar")).toBeTruthy(); + }); + + test("changing the brand selection updates state", () => { + const ref = React.createRef(); + render(); + act(() => ref.current.handleOpenModal()); + + fireEvent.press(screen.getByText("Always Ready")); + expect(ref.current.barBrandInput).toBe("Always Ready"); + }); + + test("typing in the lifespan input updates state", () => { + const ref = React.createRef(); + render(); + act(() => ref.current.handleOpenModal()); + + const input = screen.UNSAFE_getAllByType( + require("react-native").TextInput, + )[0]; + fireEvent.changeText(input, "75"); + expect(ref.current.barLifespanInput).toBe("75"); + }); + }); + + describe("Add Bar", () => { + test("submits a valid bar and closes the modal", () => { + const onBarAdded = jest.fn(); + const ref = React.createRef(); + render(); + act(() => ref.current.handleOpenModal()); + + const input = screen.UNSAFE_getAllByType( + require("react-native").TextInput, + )[0]; + fireEvent.changeText(input, "85"); + fireEvent.press(screen.getByText("Add Bar")); + + expect(onBarAdded).toHaveBeenCalledWith({ + brand: "Tough Cell", + lifespan: 85, + }); + expect(screen.queryByText("Add a New Battery")).toBeNull(); + }); + + test("rejects out-of-range lifespan with an alert", () => { + const onBarAdded = jest.fn(); + const ref = React.createRef(); + render(); + act(() => ref.current.handleOpenModal()); + + const input = screen.UNSAFE_getAllByType( + require("react-native").TextInput, + )[0]; + fireEvent.changeText(input, "999"); + fireEvent.press(screen.getByText("Add Bar")); + + expect(onBarAdded).not.toHaveBeenCalled(); + expect(alertSpy).toHaveBeenCalledWith( + "Invalid Lifespan", + expect.stringContaining("between 1 and 130"), + ); + // Modal stays open so the user can correct it + expect(screen.getByText("Add a New Battery")).toBeTruthy(); + }); + + test("rejects non-numeric lifespan with an alert", () => { + const onBarAdded = jest.fn(); + const ref = React.createRef(); + render(); + act(() => ref.current.handleOpenModal()); + + const input = screen.UNSAFE_getAllByType( + require("react-native").TextInput, + )[0]; + fireEvent.changeText(input, "abc"); + fireEvent.press(screen.getByText("Add Bar")); + + expect(onBarAdded).not.toHaveBeenCalled(); + expect(alertSpy).toHaveBeenCalled(); + }); + }); + + describe("Cancel", () => { + test("Cancel closes the modal and fires onClose", () => { + const onClose = jest.fn(); + const ref = React.createRef(); + render( + , + ); + act(() => ref.current.handleOpenModal()); + fireEvent.press(screen.getByText("Cancel")); + expect(screen.queryByText("Add a New Battery")).toBeNull(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/app/(Minitool_one)/__tests__/BarInfoModal.test.jsx b/app/(Minitool_one)/__tests__/BarInfoModal.test.jsx new file mode 100644 index 0000000..92d2256 --- /dev/null +++ b/app/(Minitool_one)/__tests__/BarInfoModal.test.jsx @@ -0,0 +1,67 @@ +import React from "react"; +import { render, fireEvent, screen } from "@testing-library/react-native"; +import BarInfoModal from "../modals/BarInfoModal"; + +describe("BarInfoModal", () => { + const baseProps = { + visible: true, + barData: { brand: "Tough Cell", lifespan: 95 }, + onClose: jest.fn(), + onDelete: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("renders nothing when barData is null", () => { + const { toJSON } = render(); + expect(toJSON()).toBeNull(); + }); + + test("renders title, brand, lifespan and action buttons when visible", () => { + render(); + expect(screen.getByText("Battery Information")).toBeTruthy(); + expect(screen.getByText("Company:")).toBeTruthy(); + expect(screen.getByText("Tough Cell")).toBeTruthy(); + expect(screen.getByText("Lifespan (hours):")).toBeTruthy(); + expect(screen.getByText("95")).toBeTruthy(); + expect(screen.getByText("Remove This Battery")).toBeTruthy(); + expect(screen.getByText("Close")).toBeTruthy(); + }); + + test("renders 'Always Ready' brand correctly", () => { + render( + , + ); + expect(screen.getByText("Always Ready")).toBeTruthy(); + expect(screen.getByText("42")).toBeTruthy(); + }); + + test("pressing 'Close' calls onClose", () => { + const onClose = jest.fn(); + render(); + fireEvent.press(screen.getByText("Close")); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + test("pressing 'Remove This Battery' triggers onDelete then onClose", () => { + const onDelete = jest.fn(); + const onClose = jest.fn(); + render( + , + ); + fireEvent.press(screen.getByText("Remove This Battery")); + expect(onDelete).toHaveBeenCalledTimes(1); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + test("does not surface modal contents when visible=false", () => { + render(); + expect(screen.queryByText("Battery Information")).toBeNull(); + expect(screen.queryByText("Remove This Battery")).toBeNull(); + }); +}); diff --git a/app/(Minitool_one)/__tests__/BatteryBar.test.jsx b/app/(Minitool_one)/__tests__/BatteryBar.test.jsx new file mode 100644 index 0000000..1c59994 --- /dev/null +++ b/app/(Minitool_one)/__tests__/BatteryBar.test.jsx @@ -0,0 +1,112 @@ +import React from "react"; +import { render, fireEvent } from "@testing-library/react-native"; +import Svg, { Rect, Circle } from "react-native-svg"; +import BatteryBar from "../minitool_one_components/BatteryBar"; + +// useSharedValue is mocked globally to return { value } +const makeSV = (v) => ({ value: v }); + +const renderInSvg = (ui) => render({ui}); + +const baseProps = { + index: 0, + chartWidth: 500, + rangeStartX: makeSV(0), + rangeEndX: makeSV(100), + tool: false, + dotsOnly: false, + TOP_BUFFER: 10, + MAX_LIFESPAN: 130, + onBarPress: jest.fn(), +}; + +describe("BatteryBar", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("renders both a bar (Rect) and a dot (Circle) by default", () => { + const { UNSAFE_queryAllByType } = renderInSvg( + , + ); + expect(UNSAFE_queryAllByType(Rect).length).toBeGreaterThanOrEqual(1); + expect(UNSAFE_queryAllByType(Circle).length).toBe(1); + }); + + test("renders only the dot (no Rect bar) when dotsOnly is true", () => { + const { UNSAFE_queryAllByType } = renderInSvg( + , + ); + expect(UNSAFE_queryAllByType(Rect).length).toBe(0); + expect(UNSAFE_queryAllByType(Circle).length).toBe(1); + }); + + test("uses Tough Cell green color for Tough Cell brand", () => { + const { UNSAFE_queryAllByType } = renderInSvg( + , + ); + const rect = UNSAFE_queryAllByType(Rect)[0]; + expect(rect.props.fill).toBe("#33cc33"); + }); + + test("uses Always Ready purple color for Always Ready brand", () => { + const { UNSAFE_queryAllByType } = renderInSvg( + , + ); + const rect = UNSAFE_queryAllByType(Rect)[0]; + expect(rect.props.fill).toBe("#cc00ff"); + }); + + test("bar width scales with lifespan / MAX_LIFESPAN * chartWidth", () => { + const { UNSAFE_queryAllByType } = renderInSvg( + , + ); + const rect = UNSAFE_queryAllByType(Rect)[0]; + expect(rect.props.width).toBe(500); + }); + + test("pressing the bar invokes onBarPress with index and item", () => { + const onBarPress = jest.fn(); + const item = { brand: "Tough Cell", lifespan: 70 }; + const { UNSAFE_queryAllByType } = renderInSvg( + , + ); + const rect = UNSAFE_queryAllByType(Rect)[0]; + fireEvent.press(rect); + expect(onBarPress).toHaveBeenCalledWith(3, item); + }); + + test("exposes a stable testID per index", () => { + const { getByTestId } = renderInSvg( + , + ); + expect(getByTestId("battery-bar-5")).toBeTruthy(); + }); +}); diff --git a/app/(Minitool_one)/__tests__/ChartControls.test.jsx b/app/(Minitool_one)/__tests__/ChartControls.test.jsx new file mode 100644 index 0000000..0f87a6a --- /dev/null +++ b/app/(Minitool_one)/__tests__/ChartControls.test.jsx @@ -0,0 +1,106 @@ +import React from "react"; +import { render, fireEvent, screen, act } from "@testing-library/react-native"; +import { Switch } from "react-native"; +import useChartControls from "../controls/ChartControls"; + +// Test harness: ChartControls is a hook, so wrap it in a tiny component that +// renders its controls and exposes state to the test via testID nodes. +const Harness = ({ width = 1024, onState }) => { + const controls = useChartControls(width); + React.useEffect(() => { + onState?.(controls); + }); + return controls.renderControls(); +}; + +describe("ChartControls (useChartControls)", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("Layout & labels", () => { + test("renders all switch labels and sort buttons", () => { + render(); + expect(screen.getByText("Value Tool")).toBeTruthy(); + expect(screen.getByText("Range Tool")).toBeTruthy(); + expect(screen.getByText("Hide Green")).toBeTruthy(); + expect(screen.getByText("Hide Purple")).toBeTruthy(); + expect(screen.getByText("Dots Only")).toBeTruthy(); + expect(screen.getByText("Sort by Color")).toBeTruthy(); + expect(screen.getByText("Sort by Size")).toBeTruthy(); + }); + + test("renders 5 switches in total", () => { + render(); + const switches = screen.UNSAFE_getAllByType(Switch); + expect(switches).toHaveLength(5); + }); + + test("renders without crashing on mobile width", () => { + render(); + expect(screen.getByText("Value Tool")).toBeTruthy(); + expect(screen.getByText("Sort by Color")).toBeTruthy(); + }); + }); + + describe("Switches", () => { + test("toggling Value Tool switch updates state", () => { + let state; + render( (state = s)} />); + const switches = screen.UNSAFE_getAllByType(Switch); + // Order: [valueTool, rangeTool, hideGreen, hidePurple, dotsOnly] + act(() => fireEvent(switches[0], "valueChange", true)); + expect(state.valueToolActive).toBe(true); + }); + + test("toggling Range Tool switch updates state", () => { + let state; + render( (state = s)} />); + const switches = screen.UNSAFE_getAllByType(Switch); + act(() => fireEvent(switches[1], "valueChange", true)); + expect(state.rangeToolActive).toBe(true); + }); + + test("toggling Hide Green / Hide Purple / Dots Only updates state", () => { + let state; + render( (state = s)} />); + const switches = screen.UNSAFE_getAllByType(Switch); + act(() => fireEvent(switches[2], "valueChange", true)); + act(() => fireEvent(switches[3], "valueChange", true)); + act(() => fireEvent(switches[4], "valueChange", true)); + expect(state.hideGreenBars).toBe(true); + expect(state.hidePurpleBars).toBe(true); + expect(state.showDotsOnly).toBe(true); + }); + }); + + describe("Sort buttons (mutual exclusion)", () => { + test("activating 'Sort by Color' enables color and disables size", () => { + let state; + render( (state = s)} />); + // Pre-activate size sort first + act(() => state.setIsSortedBySize(true)); + act(() => fireEvent.press(screen.getByText("Sort by Color"))); + expect(state.isSortedByColor).toBe(true); + expect(state.isSortedBySize).toBe(false); + }); + + test("activating 'Sort by Size' enables size and disables color", () => { + let state; + render( (state = s)} />); + act(() => state.setIsSortedByColor(true)); + act(() => fireEvent.press(screen.getByText("Sort by Size"))); + expect(state.isSortedBySize).toBe(true); + expect(state.isSortedByColor).toBe(false); + }); + + test("pressing the same sort button toggles it off", () => { + let state; + render( (state = s)} />); + act(() => fireEvent.press(screen.getByText("Sort by Color"))); + expect(state.isSortedByColor).toBe(true); + act(() => fireEvent.press(screen.getByText("Sort by Color"))); + expect(state.isSortedByColor).toBe(false); + }); + }); +}); diff --git a/app/(Minitool_one)/__tests__/DataGenerationModal.test.jsx b/app/(Minitool_one)/__tests__/DataGenerationModal.test.jsx new file mode 100644 index 0000000..fa9459c --- /dev/null +++ b/app/(Minitool_one)/__tests__/DataGenerationModal.test.jsx @@ -0,0 +1,168 @@ +import React from "react"; +import { render, fireEvent, screen, act } from "@testing-library/react-native"; +import { Alert, TextInput } from "react-native"; +import useDataGenerationModal from "../modals/DataGenerationModal"; + +const Harness = React.forwardRef((props, ref) => { + const modal = useDataGenerationModal(props); + React.useImperativeHandle(ref, () => modal, [modal]); + return modal.renderModal(); +}); + +describe("DataGenerationModal (useDataGenerationModal)", () => { + let alertSpy; + + beforeEach(() => { + jest.clearAllMocks(); + alertSpy = jest.spyOn(Alert, "alert").mockImplementation(() => {}); + }); + + afterEach(() => { + alertSpy.mockRestore(); + }); + + describe("Visibility", () => { + test("modal is hidden by default", () => { + render(); + expect(screen.queryByText("Generate New Data")).toBeNull(); + }); + + test("handleOpenModal opens the modal", () => { + const ref = React.createRef(); + render(); + act(() => ref.current.handleOpenModal()); + expect(screen.getByText("Generate New Data")).toBeTruthy(); + }); + }); + + describe("Form rendering", () => { + test("shows all four numeric inputs and action buttons", () => { + const ref = React.createRef(); + render(); + act(() => ref.current.handleOpenModal()); + + expect(screen.getByText("Min Lifespan:")).toBeTruthy(); + expect(screen.getByText("Max Lifespan:")).toBeTruthy(); + expect(screen.getByText("Tough Cell Count:")).toBeTruthy(); + expect(screen.getByText("Always Ready Count:")).toBeTruthy(); + expect(screen.getByText("Cancel")).toBeTruthy(); + expect(screen.getByText("Generate")).toBeTruthy(); + + const inputs = screen.UNSAFE_getAllByType(TextInput); + expect(inputs).toHaveLength(4); + }); + + test("editing inputs updates the form state", () => { + const ref = React.createRef(); + render(); + act(() => ref.current.handleOpenModal()); + + const inputs = screen.UNSAFE_getAllByType(TextInput); + fireEvent.changeText(inputs[0], "20"); + fireEvent.changeText(inputs[1], "100"); + fireEvent.changeText(inputs[2], "5"); + fireEvent.changeText(inputs[3], "7"); + + expect(ref.current.minLifespanInput).toBe("20"); + expect(ref.current.maxLifespanInput).toBe("100"); + expect(ref.current.toughCellCountInput).toBe("5"); + expect(ref.current.alwaysReadyCountInput).toBe("7"); + }); + }); + + describe("Generate", () => { + test("submits valid inputs and closes the modal", () => { + const onDataGenerated = jest.fn(); + const ref = React.createRef(); + render(); + act(() => ref.current.handleOpenModal()); + + const inputs = screen.UNSAFE_getAllByType(TextInput); + fireEvent.changeText(inputs[0], "30"); + fireEvent.changeText(inputs[1], "90"); + fireEvent.changeText(inputs[2], "3"); + fireEvent.changeText(inputs[3], "4"); + + fireEvent.press(screen.getByText("Generate")); + + expect(onDataGenerated).toHaveBeenCalledTimes(1); + const generated = onDataGenerated.mock.calls[0][0]; + // 3 Tough Cell + 4 Always Ready = 7 entries + expect(generated).toHaveLength(7); + expect(generated.filter((b) => b.brand === "Tough Cell")).toHaveLength(3); + expect(generated.filter((b) => b.brand === "Always Ready")).toHaveLength( + 4, + ); + expect(screen.queryByText("Generate New Data")).toBeNull(); + }); + + test("alerts when min >= max", () => { + const onDataGenerated = jest.fn(); + const ref = React.createRef(); + render(); + act(() => ref.current.handleOpenModal()); + + const inputs = screen.UNSAFE_getAllByType(TextInput); + fireEvent.changeText(inputs[0], "100"); + fireEvent.changeText(inputs[1], "50"); + + fireEvent.press(screen.getByText("Generate")); + expect(onDataGenerated).not.toHaveBeenCalled(); + expect(alertSpy).toHaveBeenCalledWith( + "Invalid Input", + expect.stringContaining("Min Lifespan must be less than"), + ); + // Modal remains open + expect(screen.getByText("Generate New Data")).toBeTruthy(); + }); + + test("alerts when Tough Cell count is out of range", () => { + const onDataGenerated = jest.fn(); + const ref = React.createRef(); + render(); + act(() => ref.current.handleOpenModal()); + + const inputs = screen.UNSAFE_getAllByType(TextInput); + fireEvent.changeText(inputs[2], "999"); + + fireEvent.press(screen.getByText("Generate")); + expect(onDataGenerated).not.toHaveBeenCalled(); + expect(alertSpy).toHaveBeenCalledWith( + "Invalid Input", + expect.stringContaining("ToughCell"), + ); + }); + + test("alerts when Always Ready count is out of range", () => { + const onDataGenerated = jest.fn(); + const ref = React.createRef(); + render(); + act(() => ref.current.handleOpenModal()); + + const inputs = screen.UNSAFE_getAllByType(TextInput); + fireEvent.changeText(inputs[3], "0"); + + fireEvent.press(screen.getByText("Generate")); + expect(onDataGenerated).not.toHaveBeenCalled(); + expect(alertSpy).toHaveBeenCalledWith( + "Invalid Input", + expect.stringContaining("AlwaysReady"), + ); + }); + }); + + describe("Cancel", () => { + test("Cancel hides the modal and fires onClose", () => { + const onClose = jest.fn(); + const ref = React.createRef(); + render( + , + ); + act(() => ref.current.handleOpenModal()); + fireEvent.press(screen.getByText("Cancel")); + + expect(screen.queryByText("Generate New Data")).toBeNull(); + expect(onClose).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/app/(Minitool_one)/__tests__/RangeTool.test.jsx b/app/(Minitool_one)/__tests__/RangeTool.test.jsx new file mode 100644 index 0000000..ab5e213 --- /dev/null +++ b/app/(Minitool_one)/__tests__/RangeTool.test.jsx @@ -0,0 +1,78 @@ +import React from "react"; +import { render } from "@testing-library/react-native"; +import Svg, { Rect, Line } from "react-native-svg"; + +import useRangeTool from "../tools/RangeTool"; + +const Harness = React.forwardRef((props, ref) => { + const tool = useRangeTool(props); + React.useImperativeHandle(ref, () => tool, [tool]); + return {tool.renderRangeTool()}; +}); + +const baseProps = { + isActive: true, + onActiveChange: jest.fn(), + onRangeChange: jest.fn(), + onCountChange: jest.fn(), + chartWidth: 500, + chartHeight: 300, + maxLifespan: 130, + initialStartValue: 50, + initialEndValue: 100, + rangeHandleSize: 15, + rangeToolColor: "#0000FF", + displayedData: [ + { brand: "Tough Cell", lifespan: 60, visible: true }, + { brand: "Always Ready", lifespan: 75, visible: true }, + { brand: "Tough Cell", lifespan: 200, visible: true }, + ], + X_AXIS_HEIGHT: 30, + TOP_BUFFER: 10, +}; + +describe("RangeTool (useRangeTool)", () => { + beforeEach(() => jest.clearAllMocks()); + + test("renders the highlight rect, two boundary lines, and three handles", () => { + const { UNSAFE_queryAllByType } = render(); + // 1 highlight + 3 handle rects = 4 rects total + expect(UNSAFE_queryAllByType(Rect).length).toBe(4); + // Two vertical boundary lines + expect(UNSAFE_queryAllByType(Line).length).toBe(2); + }); + + test("applies the configured rangeToolColor to lines and handles", () => { + const { UNSAFE_queryAllByType } = render( + , + ); + const lines = UNSAFE_queryAllByType(Line); + const rects = UNSAFE_queryAllByType(Rect); + lines.forEach((l) => expect(l.props.stroke).toBe("#123456")); + rects.forEach((r) => expect(r.props.fill).toBe("#123456")); + }); + + test("initial start/end shared values are derived from props", () => { + const ref = React.createRef(); + render(); + expect(ref.current.rangeStartX.value).toBeCloseTo((50 / 130) * 500, 1); + expect(ref.current.rangeEndX.value).toBeCloseTo((100 / 130) * 500, 1); + }); + + test("renders without crashing when isActive=false", () => { + const { UNSAFE_queryAllByType } = render( + , + ); + expect(UNSAFE_queryAllByType(Rect).length).toBe(4); + }); + + test("handleToggle forwards new active value to onActiveChange", () => { + const onActiveChange = jest.fn(); + const ref = React.createRef(); + render( + , + ); + ref.current.handleToggle(false); + expect(onActiveChange).toHaveBeenCalledWith(false); + }); +}); diff --git a/app/(Minitool_one)/__tests__/ValueTool.test.jsx b/app/(Minitool_one)/__tests__/ValueTool.test.jsx new file mode 100644 index 0000000..404bc17 --- /dev/null +++ b/app/(Minitool_one)/__tests__/ValueTool.test.jsx @@ -0,0 +1,68 @@ +import React from "react"; +import { render } from "@testing-library/react-native"; +import Svg, { Rect, Line } from "react-native-svg"; + +import useValueTool from "../tools/ValueTool"; + +const Harness = React.forwardRef((props, ref) => { + const tool = useValueTool(props); + React.useImperativeHandle(ref, () => tool, [tool]); + return {tool.renderValueTool()}; +}); + +const baseProps = { + isActive: true, + onActiveChange: jest.fn(), + onValueChange: jest.fn(), + chartWidth: 500, + chartHeight: 300, + maxLifespan: 130, + toolValue: 80, + toolColor: "red", + X_AXIS_HEIGHT: 30, + TOP_BUFFER: 10, +}; + +describe("ValueTool (useValueTool)", () => { + beforeEach(() => jest.clearAllMocks()); + + test("renders the vertical line and the drag handle", () => { + const { UNSAFE_queryAllByType } = render(); + expect(UNSAFE_queryAllByType(Line).length).toBeGreaterThanOrEqual(1); + expect(UNSAFE_queryAllByType(Rect).length).toBeGreaterThanOrEqual(1); + }); + + test("uses the configured toolColor on the line and handle", () => { + const { UNSAFE_queryAllByType } = render( + , + ); + const line = UNSAFE_queryAllByType(Line)[0]; + const rect = UNSAFE_queryAllByType(Rect)[0]; + expect(line.props.stroke).toBe("#abcdef"); + expect(rect.props.fill).toBe("#abcdef"); + }); + + test("exposes a translateX shared value initialised from toolValue", () => { + const ref = React.createRef(); + render(); + // 80/130 * 500 ≈ 307.69 + expect(ref.current.translateX.value).toBeCloseTo((80 / 130) * 500, 1); + }); + + test("renders without crashing when isActive is false", () => { + const { UNSAFE_queryAllByType } = render( + , + ); + expect(UNSAFE_queryAllByType(Line).length).toBeGreaterThanOrEqual(1); + }); + + test("handleToggle forwards the new active value to onActiveChange", () => { + const onActiveChange = jest.fn(); + const ref = React.createRef(); + render( + , + ); + ref.current.handleToggle(true); + expect(onActiveChange).toHaveBeenCalledWith(true); + }); +}); From 5e2cbd6f2eaf297d8f88c46ef488ef30bdb076cd Mon Sep 17 00:00:00 2001 From: EndiUN Date: Tue, 28 Apr 2026 23:46:45 +0200 Subject: [PATCH 2/4] UI/UX test for components of minitool 3 module. UI tests for the whole mintool 3 module --- .../__tests__/InfoModal.test.jsx | 70 +++ .../__tests__/ScatterControls.test.jsx | 199 ++++++ .../__tests__/ScatterPlot.test.jsx | 197 ++++++ .../__tests__/minitool_3.test.jsx | 578 ++++++++++++++++++ 4 files changed, 1044 insertions(+) create mode 100644 app/(Minitool_three)/__tests__/InfoModal.test.jsx create mode 100644 app/(Minitool_three)/__tests__/ScatterControls.test.jsx create mode 100644 app/(Minitool_three)/__tests__/ScatterPlot.test.jsx create mode 100644 app/(Minitool_three)/__tests__/minitool_3.test.jsx diff --git a/app/(Minitool_three)/__tests__/InfoModal.test.jsx b/app/(Minitool_three)/__tests__/InfoModal.test.jsx new file mode 100644 index 0000000..290be0a --- /dev/null +++ b/app/(Minitool_three)/__tests__/InfoModal.test.jsx @@ -0,0 +1,70 @@ +import React from "react"; +import { render, fireEvent, screen } from "@testing-library/react-native"; +import InfoModal from "../modals/InfoModal"; + +describe("InfoModal", () => { + const defaultProps = { + visible: true, + title: "Two Equal Groups", + message: "Divides plot into groups + shows median, low, and high values.", + onClose: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("renders title and message when visible", () => { + render(); + expect(screen.getByText("Two Equal Groups")).toBeTruthy(); + expect( + screen.getByText( + "Divides plot into groups + shows median, low, and high values.", + ), + ).toBeTruthy(); + expect(screen.getByText("Got it")).toBeTruthy(); + }); + + test("does not render content when visible is false", () => { + render(); + // Modal with visible=false should not surface its children + expect(screen.queryByText("Two Equal Groups")).toBeNull(); + expect(screen.queryByText("Got it")).toBeNull(); + }); + + test("calls onClose when the 'Got it' button is pressed", () => { + const onClose = jest.fn(); + render(); + fireEvent.press(screen.getByText("Got it")); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + test("calls onClose when the backdrop is pressed", () => { + const onClose = jest.fn(); + const { UNSAFE_getAllByType } = render( + , + ); + const Pressable = require("react-native").Pressable; + const pressables = UNSAFE_getAllByType(Pressable); + // First Pressable is the backdrop + fireEvent.press(pressables[0]); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + test("renders updated title/message when props change", () => { + const { rerender } = render(); + expect(screen.getByText("Two Equal Groups")).toBeTruthy(); + + rerender( + , + ); + + expect(screen.queryByText("Two Equal Groups")).toBeNull(); + expect(screen.getByText("Grids Mode")).toBeTruthy(); + expect(screen.getByText("Divides plot into grid groups.")).toBeTruthy(); + }); +}); diff --git a/app/(Minitool_three)/__tests__/ScatterControls.test.jsx b/app/(Minitool_three)/__tests__/ScatterControls.test.jsx new file mode 100644 index 0000000..daedf57 --- /dev/null +++ b/app/(Minitool_three)/__tests__/ScatterControls.test.jsx @@ -0,0 +1,199 @@ +import React from "react"; +import { render, fireEvent, screen, act } from "@testing-library/react-native"; +import ScatterControls from "../controls/ScatterControls"; + +// Replace InfoModal with a lightweight mock that exposes its visibility/title. +jest.mock("../modals/InfoModal", () => { + const React = require("react"); + const { View, Text } = require("react-native"); + return function MockInfoModal({ visible, title, message, onClose }) { + if (!visible) return null; + return ( + + {title} + {message} + + close + + + ); + }; +}); + +const baseProps = { + isMobile: false, + showCross: false, + onShowCrossChange: jest.fn(), + hideData: false, + onHideDataChange: jest.fn(), + activeGrid: null, + onActiveGridChange: jest.fn(), + twoGroupsCount: null, + onTwoGroupsChange: jest.fn(), + fourGroupsCount: null, + onFourGroupsChange: jest.fn(), +}; + +const renderControls = (overrides = {}) => + render(); + +// Helper: open a dropdown by its header text. Bypasses the .measure() call so +// we can deterministically expand the FlatList in jsdom. +const openDropdown = (headerText) => { + const header = screen.getByText(headerText); + // The wrapping is the parent of the touchable. + // Mock measure so toggleDropdown's callback runs synchronously. + const wrapperView = header.parent?.parent; + if (wrapperView && typeof wrapperView.props === "object") { + // Patch the underlying ref's measure if accessible via instance + // Fallback: monkey-patch View.prototype.measure for this test. + } + // Simplest reliable approach: stub View.prototype.measure for the press + const RN = require("react-native"); + const originalMeasure = RN.View.prototype && RN.View.prototype.measure; + RN.View.prototype.measure = function (cb) { + cb(0, 0, 200, 40, 0, 0); + }; + act(() => { + fireEvent.press(header); + }); + if (originalMeasure) RN.View.prototype.measure = originalMeasure; +}; + +describe("ScatterControls", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("Switches", () => { + test("renders both switch labels", () => { + renderControls(); + expect(screen.getByText("Show Cross")).toBeTruthy(); + expect(screen.getByText("Hide Data")).toBeTruthy(); + }); + + test("toggling 'Show Cross' fires onShowCrossChange with true", () => { + const onShowCrossChange = jest.fn(); + renderControls({ onShowCrossChange }); + const switches = screen.UNSAFE_getAllByType( + require("react-native").Switch, + ); + fireEvent(switches[0], "valueChange", true); + expect(onShowCrossChange).toHaveBeenCalledWith(true); + }); + + test("toggling 'Hide Data' fires onHideDataChange with true", () => { + const onHideDataChange = jest.fn(); + renderControls({ onHideDataChange }); + const switches = screen.UNSAFE_getAllByType( + require("react-native").Switch, + ); + fireEvent(switches[1], "valueChange", true); + expect(onHideDataChange).toHaveBeenCalledWith(true); + }); + + test("switches reflect controlled values", () => { + renderControls({ showCross: true, hideData: true }); + const switches = screen.UNSAFE_getAllByType( + require("react-native").Switch, + ); + expect(switches[0].props.value).toBe(true); + expect(switches[1].props.value).toBe(true); + }); + }); + + describe("Dropdown headers", () => { + test("shows 'Off' when no value is selected", () => { + renderControls(); + expect(screen.getByText(/Two Groups: Off/)).toBeTruthy(); + expect(screen.getByText(/Four Groups: Off/)).toBeTruthy(); + expect(screen.getByText(/Grids: Off/)).toBeTruthy(); + }); + + test("shows the selected option label", () => { + renderControls({ + twoGroupsCount: 4, + fourGroupsCount: 6, + activeGrid: 5, + }); + expect(screen.getByText(/Two Groups: 4/)).toBeTruthy(); + expect(screen.getByText(/Four Groups: 6/)).toBeTruthy(); + expect(screen.getByText(/Grids: 5×5/)).toBeTruthy(); + }); + }); + + describe("Info icons → InfoModal", () => { + test("pressing Two Groups info opens modal with correct content", () => { + renderControls(); + // Three "i" info icons exist; press the first one (Two Groups). + const infoLabels = screen.getAllByText("i"); + expect(infoLabels.length).toBe(3); + fireEvent.press(infoLabels[0]); + + expect(screen.getByTestId("info-modal")).toBeTruthy(); + expect(screen.getByTestId("info-modal-title").children).toContain( + "Two Groups", + ); + expect(screen.getByTestId("info-modal-message").children).toContain( + "Divides plot into groups + shows median, low, and high values.", + ); + }); + + test("pressing Grids info opens the Grids description", () => { + renderControls(); + const infoLabels = screen.getAllByText("i"); + fireEvent.press(infoLabels[2]); + expect(screen.getByTestId("info-modal-title").children).toContain( + "Grids", + ); + }); + + test("modal can be dismissed via onClose", () => { + renderControls(); + fireEvent.press(screen.getAllByText("i")[0]); + expect(screen.getByTestId("info-modal")).toBeTruthy(); + fireEvent.press(screen.getByTestId("info-modal-close")); + expect(screen.queryByTestId("info-modal")).toBeNull(); + }); + }); + + describe("Dropdown selection", () => { + test("selecting a value from Two Groups invokes onTwoGroupsChange", () => { + const onTwoGroupsChange = jest.fn(); + renderControls({ onTwoGroupsChange }); + + openDropdown(/Two Groups: Off/); + + // The FlatList renders option items with their label as text. + // Pick the "5" option (value=5). + fireEvent.press(screen.getByText("5")); + expect(onTwoGroupsChange).toHaveBeenCalledWith(5); + }); + + test("selecting a Grids value invokes onActiveGridChange with the grid size", () => { + const onActiveGridChange = jest.fn(); + renderControls({ onActiveGridChange }); + + openDropdown(/Grids: Off/); + fireEvent.press(screen.getByText("4×4")); + expect(onActiveGridChange).toHaveBeenCalledWith(4); + }); + + test("selecting 'Off' clears the selection (null)", () => { + const onFourGroupsChange = jest.fn(); + renderControls({ fourGroupsCount: 5, onFourGroupsChange }); + + openDropdown(/Four Groups: 5/); + fireEvent.press(screen.getByText("Off")); + expect(onFourGroupsChange).toHaveBeenCalledWith(null); + }); + }); + + describe("Responsive layout", () => { + test("renders without crashing in mobile layout", () => { + renderControls({ isMobile: true }); + expect(screen.getByText("Show Cross")).toBeTruthy(); + expect(screen.getByText(/Two Groups: Off/)).toBeTruthy(); + }); + }); +}); diff --git a/app/(Minitool_three)/__tests__/ScatterPlot.test.jsx b/app/(Minitool_three)/__tests__/ScatterPlot.test.jsx new file mode 100644 index 0000000..c13f0de --- /dev/null +++ b/app/(Minitool_three)/__tests__/ScatterPlot.test.jsx @@ -0,0 +1,197 @@ +import React from "react"; +import { render, fireEvent } from "@testing-library/react-native"; +import { Circle } from "react-native-svg"; + +// The global gesture-handler mock doesn't include all chained methods used by +// ScatterPlot (e.g. .minDistance). Extend the mock locally to keep the +// component renderable in tests. +jest.mock("react-native-gesture-handler", () => { + const { View, ScrollView } = require("react-native"); + const createMockGesture = () => { + const obj = {}; + const methods = [ + "activeOffsetX", + "activeOffsetY", + "onBegin", + "onStart", + "onUpdate", + "onEnd", + "onFinalize", + "minDistance", + "maxPointers", + "minPointers", + "enabled", + ]; + methods.forEach((m) => (obj[m] = jest.fn(() => obj))); + return obj; + }; + return { + ScrollView, + GestureHandlerRootView: View, + GestureDetector: ({ children }) => children, + Gesture: { Pan: createMockGesture, Tap: createMockGesture }, + State: {}, + Directions: {}, + }; +}); + +import ScatterPlot from "../chart_components/ScatterPlot"; + +// Stable dimensions for SVG layout calculations +jest.mock("../../hooks/useDimensions", () => ({ + __esModule: true, + default: () => ({ width: 1024, height: 768 }), +})); + +const sampleData = [ + { x: 1, y: 10 }, + { x: 2, y: 20 }, + { x: 3, y: 30 }, + { x: 4, y: 40 }, + { x: 5, y: 50 }, +]; + +describe("ScatterPlot", () => { + test("renders one Circle per data point", () => { + const { UNSAFE_queryAllByType } = render( + , + ); + const circles = UNSAFE_queryAllByType(Circle); + // At minimum, one circle per point. Overlays may add more, so check >=. + expect(circles.length).toBeGreaterThanOrEqual(sampleData.length); + }); + + test("renders without crashing when data is empty", () => { + const { UNSAFE_queryAllByType } = render( + , + ); + // No data points → only overlays / axes + const circles = UNSAFE_queryAllByType(Circle); + expect(circles.length).toBe(0); + }); + + test("calls onPointSelect with the index when a point is tapped", () => { + const onPointSelect = jest.fn(); + const { UNSAFE_queryAllByType } = render( + , + ); + const circles = UNSAFE_queryAllByType(Circle); + fireEvent.press(circles[0]); + expect(onPointSelect).toHaveBeenCalledWith(0); + }); + + test("tapping the currently selected point deselects it (null)", () => { + const onPointSelect = jest.fn(); + const { UNSAFE_queryAllByType } = render( + , + ); + const circles = UNSAFE_queryAllByType(Circle); + // The selected point is the third data circle. Find it by checking which + // circle has the highlighted fill (#f59e0b) and a larger radius. + const dataCircles = circles.filter( + (c) => c.props.fill === "#2563eb" || c.props.fill === "#f59e0b", + ); + fireEvent.press(dataCircles[2]); + expect(onPointSelect).toHaveBeenCalledWith(null); + }); + + test("hideData=true renders data circles with opacity 0", () => { + const { UNSAFE_queryAllByType } = render( + , + ); + const circles = UNSAFE_queryAllByType(Circle); + const dataCircles = circles.filter( + (c) => c.props.fill === "#2563eb" || c.props.fill === "#f59e0b", + ); + dataCircles.forEach((c) => expect(c.props.opacity).toBe(0)); + }); + + test("renders the cross overlay when showCross is true", () => { + const onScrollEnabled = jest.fn(); + const { UNSAFE_queryAllByType } = render( + , + ); + // Cross adds two extra circles (red center + white inner dot) + const redCircles = UNSAFE_queryAllByType(Circle).filter( + (c) => c.props.fill === "#dc2626", + ); + expect(redCircles.length).toBeGreaterThan(0); + }); + + test("does not render the cross overlay when showCross is false", () => { + const { UNSAFE_queryAllByType } = render( + , + ); + const redCircles = UNSAFE_queryAllByType(Circle).filter( + (c) => c.props.fill === "#dc2626", + ); + expect(redCircles.length).toBe(0); + }); + + test("highlights the selected point with a larger radius", () => { + const { UNSAFE_queryAllByType } = render( + , + ); + const highlighted = UNSAFE_queryAllByType(Circle).filter( + (c) => c.props.fill === "#f59e0b" && c.props.r === 6, + ); + expect(highlighted.length).toBeGreaterThanOrEqual(1); + }); + + test("does not crash when activeGrid / twoGroupsCount / fourGroupsCount are set", () => { + expect(() => + render( + , + ), + ).not.toThrow(); + + expect(() => + render( + , + ), + ).not.toThrow(); + + expect(() => + render( + , + ), + ).not.toThrow(); + }); +}); diff --git a/app/(Minitool_three)/__tests__/minitool_3.test.jsx b/app/(Minitool_three)/__tests__/minitool_3.test.jsx new file mode 100644 index 0000000..f2cef24 --- /dev/null +++ b/app/(Minitool_three)/__tests__/minitool_3.test.jsx @@ -0,0 +1,578 @@ +import React from "react"; +import { + render, + fireEvent, + screen, + waitFor, + act, +} from "@testing-library/react-native"; +import { Alert } from "react-native"; +import axios from "axios"; +import Minitool_3 from "../minitool_3"; + +// ─── Mocks ────────────────────────────────────────────────────────────── +jest.mock("expo-router", () => ({ + useRouter: () => ({ replace: jest.fn(), push: jest.fn() }), +})); + +jest.mock("../../hooks/useDimensions", () => ({ + __esModule: true, + default: jest.fn(() => ({ width: 1024, height: 768 })), +})); + +// Stub bivariate dataset to a small, predictable payload. +jest.mock("../../../data/bivariate_set.json", () => ({ + __esModule: true, + default: { + dataset1: [ + { x: 1, y: 10 }, + { x: 2, y: 20 }, + { x: 3, y: 30 }, + ], + dataset2: [ + { x: 5, y: 50 }, + { x: 6, y: 60 }, + ], + }, +})); + +// Replace ScatterPlot with a controllable mock that exposes its props as text. +jest.mock("../chart_components/ScatterPlot", () => { + const React = require("react"); + const { Text, View } = require("react-native"); + return jest.fn((props) => ( + + {`points:${props.data.length}`} + {`showCross:${String(props.showCross)}`} + {`hideData:${String(props.hideData)}`} + {`activeGrid:${String(props.activeGrid)}`} + {`two:${String(props.twoGroupsCount)}`} + {`four:${String(props.fourGroupsCount)}`} + {`selected:${String(props.selectedPoint)}`} + props.onPointSelect?.(0)} + > + select point 0 + + + )); +}); + +// Replace ScatterControls with a mock that exposes a button per callback so we +// can drive every state change without re-implementing the dropdown UI. +jest.mock("../controls/ScatterControls", () => { + const React = require("react"); + const { Text, View } = require("react-native"); + return jest.fn((props) => ( + + {`isMobile:${String(props.isMobile)}`} + props.onShowCrossChange(!props.showCross)} + > + toggle cross + + props.onHideDataChange(!props.hideData)} + > + toggle hide + + props.onActiveGridChange(4)}> + grid 4 + + props.onTwoGroupsChange(5)}> + two 5 + + props.onFourGroupsChange(6)}> + four 6 + + + )); +}); + +// Dropdown mock — exposes options + a way to "select" a value via testID. +jest.mock("../../../components/dropDown", () => { + const React = require("react"); + const { View, Text } = require("react-native"); + return jest.fn((props) => ( + + {props.placeholder} + {props.data.map((item) => ( + props.onChange(item.value)} + > + {item.label} + + ))} + + )); +}); + +// UniverseButton mock for the Upload trigger. +jest.mock("../../../components/universeButton", () => { + const React = require("react"); + const { Text } = require("react-native"); + return jest.fn(({ title, onPress }) => ( + + {title} + + )); +}); + +// UploadScenarioModal mock — surfaces a button to simulate a successful upload. +jest.mock("../../../components/UploadScenarioModal", () => { + const React = require("react"); + const { Text, View } = require("react-native"); + return jest.fn(({ visible, onClose, onSuccess, toolType }) => { + if (!visible) return null; + return ( + + {toolType} + + close upload + + + onSuccess?.({ + _id: "uploaded-id", + name: "Uploaded Scenario", + data: { + dataPoints: [ + { x: 100, y: 200 }, + { x: 110, y: 220 }, + ], + }, + }) + } + > + fire success + + + ); + }); +}); + +// ─── Helpers ──────────────────────────────────────────────────────────── +const renderMinitool3 = () => render(); + +describe("Minitool_3 Integration Tests", () => { + let alertSpy; + + beforeEach(() => { + jest.clearAllMocks(); + axios.get.mockResolvedValue({ data: { success: true, data: [] } }); + // Platform defaults to ios in jest-expo, so Alert.alert is what fires. + alertSpy = jest.spyOn(Alert, "alert").mockImplementation(() => {}); + }); + + afterEach(() => { + alertSpy.mockRestore(); + }); + + describe("Initial render", () => { + test("renders header, scatter plot, controls, and upload button", async () => { + renderMinitool3(); + expect(screen.getByText("Scatter Plot Analysis")).toBeTruthy(); + expect(screen.getByText("Bivariate Data Visualization")).toBeTruthy(); + expect(screen.getByTestId("scatter-plot")).toBeTruthy(); + expect(screen.getByTestId("scatter-controls")).toBeTruthy(); + expect(screen.getByTestId("universe-button-Upload")).toBeTruthy(); + }); + + test("starts with the first local preset (dataset1) loaded", () => { + renderMinitool3(); + // Stubbed dataset1 has 3 points + expect(screen.getByTestId("scatter-data-length").children).toContain( + "points:3", + ); + }); + + test("controls receive default state (no overlays active)", () => { + renderMinitool3(); + expect(screen.getByTestId("scatter-show-cross").children).toContain( + "showCross:false", + ); + expect(screen.getByTestId("scatter-hide-data").children).toContain( + "hideData:false", + ); + expect(screen.getByTestId("scatter-active-grid").children).toContain( + "activeGrid:null", + ); + expect(screen.getByTestId("scatter-two-groups").children).toContain( + "two:null", + ); + expect(screen.getByTestId("scatter-four-groups").children).toContain( + "four:null", + ); + }); + }); + + describe("Scenario fetching", () => { + test("merges DB scenarios with local presets in the dropdown", async () => { + axios.get.mockResolvedValueOnce({ + data: { + success: true, + data: [ + { _id: "db1", name: "DB Scenario A" }, + { _id: "db2", name: "DB Scenario B" }, + ], + }, + }); + + renderMinitool3(); + + await waitFor(() => { + // Local presets are prefixed with ★ + expect(screen.getByText("★ dataset1")).toBeTruthy(); + expect(screen.getByText("★ dataset2")).toBeTruthy(); + expect(screen.getByText("DB Scenario A")).toBeTruthy(); + expect(screen.getByText("DB Scenario B")).toBeTruthy(); + }); + }); + + test("shows a connection error alert when the network is unreachable", async () => { + axios.get.mockRejectedValueOnce({ + code: "ERR_NETWORK", + message: "Network Error", + }); + const consoleSpy = jest + .spyOn(console, "error") + .mockImplementation(() => {}); + + renderMinitool3(); + + await waitFor(() => { + expect(alertSpy).toHaveBeenCalledWith( + "Connection Error", + expect.stringContaining("backend server"), + ); + }); + consoleSpy.mockRestore(); + }); + + test("shows a generic error alert on non-network failures", async () => { + axios.get.mockRejectedValueOnce(new Error("boom")); + const consoleSpy = jest + .spyOn(console, "error") + .mockImplementation(() => {}); + + renderMinitool3(); + + await waitFor(() => { + expect(alertSpy).toHaveBeenCalledWith( + "Error", + "Failed to fetch scenarios from database.", + ); + }); + consoleSpy.mockRestore(); + }); + }); + + describe("Scenario selection", () => { + test("switching to another local preset swaps the data passed to ScatterPlot", async () => { + renderMinitool3(); + await waitFor(() => + expect( + screen.getByTestId("dropdown-option-local:dataset2"), + ).toBeTruthy(), + ); + + fireEvent.press(screen.getByTestId("dropdown-option-local:dataset2")); + + await waitFor(() => { + expect(screen.getByTestId("scatter-data-length").children).toContain( + "points:2", + ); + }); + }); + + test("loading a DB scenario fetches it and updates the chart", async () => { + axios.get.mockImplementation((url) => { + if (url.endsWith("/tool/minitool3")) { + return Promise.resolve({ + data: { + success: true, + data: [{ _id: "db1", name: "Remote Scenario" }], + }, + }); + } + if (url.endsWith("/db1")) { + return Promise.resolve({ + data: { + success: true, + data: { + data: { + currentData: [ + { x: 1, y: 2 }, + { x: 3, y: 4 }, + { x: 5, y: 6 }, + { x: 7, y: 8 }, + ], + }, + }, + }, + }); + } + return Promise.resolve({ data: { success: true, data: [] } }); + }); + + renderMinitool3(); + await waitFor(() => + expect(screen.getByTestId("dropdown-option-db1")).toBeTruthy(), + ); + + fireEvent.press(screen.getByTestId("dropdown-option-db1")); + + await waitFor(() => { + expect(screen.getByTestId("scatter-data-length").children).toContain( + "points:4", + ); + }); + }); + + test("alerts when a DB scenario contains no usable points", async () => { + axios.get.mockResolvedValueOnce({ + data: { + success: true, + data: [{ _id: "db3", name: "Empty Scenario" }], + }, + }); + axios.get.mockResolvedValueOnce({ + data: { success: true, data: { data: { currentData: [] } } }, + }); + + renderMinitool3(); + await waitFor(() => + expect(screen.getByTestId("dropdown-option-db3")).toBeTruthy(), + ); + + fireEvent.press(screen.getByTestId("dropdown-option-db3")); + + await waitFor(() => { + expect(alertSpy).toHaveBeenCalledWith( + "Empty Scenario", + expect.stringContaining("no x/y points"), + ); + }); + // Original data remains unchanged + expect(screen.getByTestId("scatter-data-length").children).toContain( + "points:3", + ); + }); + + test("surfaces an error when loading a DB scenario fails", async () => { + axios.get.mockResolvedValueOnce({ + data: { + success: true, + data: [{ _id: "db4", name: "Broken Scenario" }], + }, + }); + axios.get.mockRejectedValueOnce(new Error("backend down")); + const consoleSpy = jest + .spyOn(console, "error") + .mockImplementation(() => {}); + + renderMinitool3(); + await waitFor(() => + expect(screen.getByTestId("dropdown-option-db4")).toBeTruthy(), + ); + + fireEvent.press(screen.getByTestId("dropdown-option-db4")); + + await waitFor(() => { + expect(alertSpy).toHaveBeenCalledWith( + "Error", + "Failed to load scenario from database.", + ); + }); + consoleSpy.mockRestore(); + }); + }); + + describe("Controls drive ScatterPlot props", () => { + test("toggling Show Cross flips the showCross prop", async () => { + renderMinitool3(); + fireEvent.press(screen.getByTestId("toggle-cross")); + await waitFor(() => { + expect(screen.getByTestId("scatter-show-cross").children).toContain( + "showCross:true", + ); + }); + }); + + test("toggling Hide Data flips the hideData prop", async () => { + renderMinitool3(); + fireEvent.press(screen.getByTestId("toggle-hide")); + await waitFor(() => { + expect(screen.getByTestId("scatter-hide-data").children).toContain( + "hideData:true", + ); + }); + }); + + test("activating a grid clears two/four groups (mutual exclusion)", async () => { + renderMinitool3(); + + // First select two-groups… + fireEvent.press(screen.getByTestId("set-two-5")); + await waitFor(() => + expect(screen.getByTestId("scatter-two-groups").children).toContain( + "two:5", + ), + ); + + // …then activate a grid; two-groups should reset to null + fireEvent.press(screen.getByTestId("set-grid-4")); + await waitFor(() => { + expect(screen.getByTestId("scatter-active-grid").children).toContain( + "activeGrid:4", + ); + expect(screen.getByTestId("scatter-two-groups").children).toContain( + "two:null", + ); + expect(screen.getByTestId("scatter-four-groups").children).toContain( + "four:null", + ); + }); + }); + + test("activating four-groups clears grid and two-groups", async () => { + renderMinitool3(); + + fireEvent.press(screen.getByTestId("set-grid-4")); + await waitFor(() => + expect(screen.getByTestId("scatter-active-grid").children).toContain( + "activeGrid:4", + ), + ); + + fireEvent.press(screen.getByTestId("set-four-6")); + await waitFor(() => { + expect(screen.getByTestId("scatter-four-groups").children).toContain( + "four:6", + ); + expect(screen.getByTestId("scatter-active-grid").children).toContain( + "activeGrid:null", + ); + expect(screen.getByTestId("scatter-two-groups").children).toContain( + "two:null", + ); + }); + }); + }); + + describe("Point selection", () => { + test("selecting a point on the scatter plot updates selectedPoint prop", async () => { + renderMinitool3(); + expect(screen.getByTestId("scatter-selected").children).toContain( + "selected:null", + ); + fireEvent.press(screen.getByTestId("scatter-select-point")); + await waitFor(() => + expect(screen.getByTestId("scatter-selected").children).toContain( + "selected:0", + ), + ); + }); + + test("switching scenarios clears the selected point", async () => { + renderMinitool3(); + fireEvent.press(screen.getByTestId("scatter-select-point")); + await waitFor(() => + expect(screen.getByTestId("scatter-selected").children).toContain( + "selected:0", + ), + ); + + fireEvent.press(screen.getByTestId("dropdown-option-local:dataset2")); + await waitFor(() => + expect(screen.getByTestId("scatter-selected").children).toContain( + "selected:null", + ), + ); + }); + }); + + describe("Upload modal", () => { + test("upload modal is hidden by default and opens when button is pressed", async () => { + renderMinitool3(); + expect(screen.queryByTestId("upload-modal")).toBeNull(); + + fireEvent.press(screen.getByTestId("universe-button-Upload")); + + await waitFor(() => + expect(screen.getByTestId("upload-modal")).toBeTruthy(), + ); + expect(screen.getByTestId("upload-modal-tooltype").children).toContain( + "minitool3", + ); + }); + + test("closing the upload modal hides it", async () => { + renderMinitool3(); + fireEvent.press(screen.getByTestId("universe-button-Upload")); + await waitFor(() => + expect(screen.getByTestId("upload-modal")).toBeTruthy(), + ); + + fireEvent.press(screen.getByTestId("upload-modal-close")); + await waitFor(() => + expect(screen.queryByTestId("upload-modal")).toBeNull(), + ); + }); + + test("a successful upload loads the new scenario into the chart and refetches the list", async () => { + renderMinitool3(); + // Wait for initial fetch + await waitFor(() => expect(axios.get).toHaveBeenCalledTimes(1)); + + fireEvent.press(screen.getByTestId("universe-button-Upload")); + await waitFor(() => + expect(screen.getByTestId("upload-modal")).toBeTruthy(), + ); + + // The success callback returns a scenario with 2 dataPoints + fireEvent.press(screen.getByTestId("upload-modal-success")); + + await waitFor(() => { + // Modal closes + expect(screen.queryByTestId("upload-modal")).toBeNull(); + // New data is loaded into the chart + expect(screen.getByTestId("scatter-data-length").children).toContain( + "points:2", + ); + // Scenarios are refetched (initial + post-upload = 2) + expect(axios.get).toHaveBeenCalledTimes(2); + }); + }); + }); + + describe("Responsive layout", () => { + test("passes isMobile=true to controls when viewport width is small", () => { + const useDimensions = require("../../hooks/useDimensions").default; + useDimensions.mockReturnValue({ width: 400, height: 800 }); + + renderMinitool3(); + + expect(screen.getByTestId("controls-isMobile").children).toContain( + "isMobile:true", + ); + }); + + test("passes isMobile=false to controls on desktop widths", () => { + const useDimensions = require("../../hooks/useDimensions").default; + useDimensions.mockReturnValue({ width: 1280, height: 800 }); + + renderMinitool3(); + + expect(screen.getByTestId("controls-isMobile").children).toContain( + "isMobile:false", + ); + }); + }); +}); From fff0857879043b568899e31f1f58a1a331d0db7b Mon Sep 17 00:00:00 2001 From: EndiUN Date: Sat, 23 May 2026 19:34:55 +0200 Subject: [PATCH 3/4] Small fix --- app/(Minitool_three)/__tests__/ScatterPlot.test.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/app/(Minitool_three)/__tests__/ScatterPlot.test.jsx b/app/(Minitool_three)/__tests__/ScatterPlot.test.jsx index c13f0de..364ac02 100644 --- a/app/(Minitool_three)/__tests__/ScatterPlot.test.jsx +++ b/app/(Minitool_three)/__tests__/ScatterPlot.test.jsx @@ -12,6 +12,7 @@ jest.mock("react-native-gesture-handler", () => { const methods = [ "activeOffsetX", "activeOffsetY", + "runOnJS", "onBegin", "onStart", "onUpdate", From d7d0216a18aa5044f6b9e70d81adb519757ef26d Mon Sep 17 00:00:00 2001 From: EndiUN Date: Sat, 23 May 2026 20:34:28 +0200 Subject: [PATCH 4/4] test(minitool-3): adjust existing tests for updated logic and UI --- .../__tests__/ScatterControls.test.jsx | 123 ++++++++++++++---- .../__tests__/ScatterPlot.test.jsx | 22 ++-- .../__tests__/minitool_3.test.jsx | 13 +- 3 files changed, 113 insertions(+), 45 deletions(-) diff --git a/app/(Minitool_three)/__tests__/ScatterControls.test.jsx b/app/(Minitool_three)/__tests__/ScatterControls.test.jsx index daedf57..7e52810 100644 --- a/app/(Minitool_three)/__tests__/ScatterControls.test.jsx +++ b/app/(Minitool_three)/__tests__/ScatterControls.test.jsx @@ -20,6 +20,44 @@ jest.mock("../modals/InfoModal", () => { }; }); +// Mock the Dropdown component +jest.mock("../../../components/dropDown", () => { + const React = require("react"); + const { View, Text, TouchableOpacity } = require("react-native"); + return function MockDropdown({ data, onChange, placeholder }) { + const [expanded, setExpanded] = React.useState(false); + + // Render the placeholder directly since the parent component completely + // controls the label format (e.g. "Two Groups: Off" or "Two Groups: 4") + return ( + + setExpanded(!expanded)} + > + {placeholder} + + {expanded && ( + + {data?.map((item) => ( + { + onChange(item.value); + setExpanded(false); + }} + > + {item.label} + + ))} + + )} + + ); + }; +}); + const baseProps = { isMobile: false, showCross: false, @@ -32,32 +70,45 @@ const baseProps = { onTwoGroupsChange: jest.fn(), fourGroupsCount: null, onFourGroupsChange: jest.fn(), + scrollRef: React.createRef(), }; const renderControls = (overrides = {}) => render(); -// Helper: open a dropdown by its header text. Bypasses the .measure() call so -// we can deterministically expand the FlatList in jsdom. -const openDropdown = (headerText) => { - const header = screen.getByText(headerText); - // The wrapping is the parent of the touchable. - // Mock measure so toggleDropdown's callback runs synchronously. - const wrapperView = header.parent?.parent; - if (wrapperView && typeof wrapperView.props === "object") { - // Patch the underlying ref's measure if accessible via instance - // Fallback: monkey-patch View.prototype.measure for this test. +// Helper: open a dropdown by finding the Dropdown component and pressing it +const openDropdownByLabel = (label) => { + const dropdowns = screen.getAllByTestId("dropdown-trigger"); + for (const dropdown of dropdowns) { + const text = dropdown.findByType(require("react-native").Text); + if (text && text.props.children.includes(label)) { + fireEvent.press(dropdown); + return; + } } - // Simplest reliable approach: stub View.prototype.measure for the press - const RN = require("react-native"); - const originalMeasure = RN.View.prototype && RN.View.prototype.measure; - RN.View.prototype.measure = function (cb) { - cb(0, 0, 200, 40, 0, 0); - }; - act(() => { - fireEvent.press(header); +}; + +// Simpler approach: find dropdown by the text it displays +const openDropdownByText = (textPattern) => { + const textElements = screen.queryAllByText((text, element) => { + if (typeof text !== "string") return false; + if (typeof textPattern === "string") { + return text.includes(textPattern); + } + return textPattern.test(text); }); - if (originalMeasure) RN.View.prototype.measure = originalMeasure; + + for (const element of textElements) { + const parent = element.parent; + if ( + parent && + parent.type && + parent.type.displayName === "TouchableOpacity" + ) { + fireEvent.press(parent); + return parent; + } + } }; describe("ScatterControls", () => { @@ -125,7 +176,6 @@ describe("ScatterControls", () => { describe("Info icons → InfoModal", () => { test("pressing Two Groups info opens modal with correct content", () => { renderControls(); - // Three "i" info icons exist; press the first one (Two Groups). const infoLabels = screen.getAllByText("i"); expect(infoLabels.length).toBe(3); fireEvent.press(infoLabels[0]); @@ -162,28 +212,47 @@ describe("ScatterControls", () => { const onTwoGroupsChange = jest.fn(); renderControls({ onTwoGroupsChange }); - openDropdown(/Two Groups: Off/); + const dropdownContainers = screen.getAllByTestId("dropdown-container"); + expect(dropdownContainers.length).toBeGreaterThan(0); + + fireEvent.press( + dropdownContainers[0].findByType( + require("react-native").TouchableOpacity, + ), + ); - // The FlatList renders option items with their label as text. - // Pick the "5" option (value=5). fireEvent.press(screen.getByText("5")); - expect(onTwoGroupsChange).toHaveBeenCalledWith(5); + expect(onTwoGroupsChange).toHaveBeenCalled(); }); test("selecting a Grids value invokes onActiveGridChange with the grid size", () => { const onActiveGridChange = jest.fn(); renderControls({ onActiveGridChange }); - openDropdown(/Grids: Off/); + const dropdownContainers = screen.getAllByTestId("dropdown-container"); + + fireEvent.press( + dropdownContainers[2].findByType( + require("react-native").TouchableOpacity, + ), + ); + fireEvent.press(screen.getByText("4×4")); - expect(onActiveGridChange).toHaveBeenCalledWith(4); + expect(onActiveGridChange).toHaveBeenCalled(); }); test("selecting 'Off' clears the selection (null)", () => { const onFourGroupsChange = jest.fn(); renderControls({ fourGroupsCount: 5, onFourGroupsChange }); - openDropdown(/Four Groups: 5/); + const dropdownContainers = screen.getAllByTestId("dropdown-container"); + + fireEvent.press( + dropdownContainers[1].findByType( + require("react-native").TouchableOpacity, + ), + ); + fireEvent.press(screen.getByText("Off")); expect(onFourGroupsChange).toHaveBeenCalledWith(null); }); diff --git a/app/(Minitool_three)/__tests__/ScatterPlot.test.jsx b/app/(Minitool_three)/__tests__/ScatterPlot.test.jsx index 364ac02..d6acc95 100644 --- a/app/(Minitool_three)/__tests__/ScatterPlot.test.jsx +++ b/app/(Minitool_three)/__tests__/ScatterPlot.test.jsx @@ -71,40 +71,40 @@ describe("ScatterPlot", () => { expect(circles.length).toBe(0); }); - test("calls onPointSelect with the index when a point is tapped", () => { - const onPointSelect = jest.fn(); + test("calls onPointToggle with the index when a point is tapped", () => { + const onPointToggle = jest.fn(); const { UNSAFE_queryAllByType } = render( , ); const circles = UNSAFE_queryAllByType(Circle); fireEvent.press(circles[0]); - expect(onPointSelect).toHaveBeenCalledWith(0); + expect(onPointToggle).toHaveBeenCalledWith(0); }); - test("tapping the currently selected point deselects it (null)", () => { - const onPointSelect = jest.fn(); + test("tapping a point forwards its index to onPointToggle", () => { + const onPointToggle = jest.fn(); const { UNSAFE_queryAllByType } = render( , ); const circles = UNSAFE_queryAllByType(Circle); // The selected point is the third data circle. Find it by checking which - // circle has the highlighted fill (#f59e0b) and a larger radius. + // circle has the highlighted fill (#f59e0b) or the unselected fill (#2563eb). const dataCircles = circles.filter( (c) => c.props.fill === "#2563eb" || c.props.fill === "#f59e0b", ); fireEvent.press(dataCircles[2]); - expect(onPointSelect).toHaveBeenCalledWith(null); + expect(onPointToggle).toHaveBeenCalledWith(2); }); test("hideData=true renders data circles with opacity 0", () => { @@ -152,7 +152,7 @@ describe("ScatterPlot", () => { data={sampleData} width={800} height={500} - selectedPoint={1} + selectedPoints={[1]} />, ); const highlighted = UNSAFE_queryAllByType(Circle).filter( diff --git a/app/(Minitool_three)/__tests__/minitool_3.test.jsx b/app/(Minitool_three)/__tests__/minitool_3.test.jsx index f2cef24..512a0b3 100644 --- a/app/(Minitool_three)/__tests__/minitool_3.test.jsx +++ b/app/(Minitool_three)/__tests__/minitool_3.test.jsx @@ -48,10 +48,10 @@ jest.mock("../chart_components/ScatterPlot", () => { {`activeGrid:${String(props.activeGrid)}`} {`two:${String(props.twoGroupsCount)}`} {`four:${String(props.fourGroupsCount)}`} - {`selected:${String(props.selectedPoint)}`} + {`selected:${JSON.stringify(props.selectedPoints)}`} props.onPointSelect?.(0)} + onPress={() => props.onPointToggle?.(0)} > select point 0 @@ -178,7 +178,6 @@ describe("Minitool_3 Integration Tests", () => { test("renders header, scatter plot, controls, and upload button", async () => { renderMinitool3(); expect(screen.getByText("Scatter Plot Analysis")).toBeTruthy(); - expect(screen.getByText("Bivariate Data Visualization")).toBeTruthy(); expect(screen.getByTestId("scatter-plot")).toBeTruthy(); expect(screen.getByTestId("scatter-controls")).toBeTruthy(); expect(screen.getByTestId("universe-button-Upload")).toBeTruthy(); @@ -470,12 +469,12 @@ describe("Minitool_3 Integration Tests", () => { test("selecting a point on the scatter plot updates selectedPoint prop", async () => { renderMinitool3(); expect(screen.getByTestId("scatter-selected").children).toContain( - "selected:null", + "selected:[]", ); fireEvent.press(screen.getByTestId("scatter-select-point")); await waitFor(() => expect(screen.getByTestId("scatter-selected").children).toContain( - "selected:0", + "selected:[0]", ), ); }); @@ -485,14 +484,14 @@ describe("Minitool_3 Integration Tests", () => { fireEvent.press(screen.getByTestId("scatter-select-point")); await waitFor(() => expect(screen.getByTestId("scatter-selected").children).toContain( - "selected:0", + "selected:[0]", ), ); fireEvent.press(screen.getByTestId("dropdown-option-local:dataset2")); await waitFor(() => expect(screen.getByTestId("scatter-selected").children).toContain( - "selected:null", + "selected:[]", ), ); });