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();
+
+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 ;
+});
+
+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 ;
+});
+
+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);
+ });
+});
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..7e52810
--- /dev/null
+++ b/app/(Minitool_three)/__tests__/ScatterControls.test.jsx
@@ -0,0 +1,268 @@
+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
+
+
+ );
+ };
+});
+
+// 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,
+ onShowCrossChange: jest.fn(),
+ hideData: false,
+ onHideDataChange: jest.fn(),
+ activeGrid: null,
+ onActiveGridChange: jest.fn(),
+ twoGroupsCount: null,
+ onTwoGroupsChange: jest.fn(),
+ fourGroupsCount: null,
+ onFourGroupsChange: jest.fn(),
+ scrollRef: React.createRef(),
+};
+
+const renderControls = (overrides = {}) =>
+ render();
+
+// 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;
+ }
+ }
+};
+
+// 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);
+ });
+
+ for (const element of textElements) {
+ const parent = element.parent;
+ if (
+ parent &&
+ parent.type &&
+ parent.type.displayName === "TouchableOpacity"
+ ) {
+ fireEvent.press(parent);
+ return parent;
+ }
+ }
+};
+
+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();
+ 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 });
+
+ const dropdownContainers = screen.getAllByTestId("dropdown-container");
+ expect(dropdownContainers.length).toBeGreaterThan(0);
+
+ fireEvent.press(
+ dropdownContainers[0].findByType(
+ require("react-native").TouchableOpacity,
+ ),
+ );
+
+ fireEvent.press(screen.getByText("5"));
+ expect(onTwoGroupsChange).toHaveBeenCalled();
+ });
+
+ test("selecting a Grids value invokes onActiveGridChange with the grid size", () => {
+ const onActiveGridChange = jest.fn();
+ renderControls({ onActiveGridChange });
+
+ const dropdownContainers = screen.getAllByTestId("dropdown-container");
+
+ fireEvent.press(
+ dropdownContainers[2].findByType(
+ require("react-native").TouchableOpacity,
+ ),
+ );
+
+ fireEvent.press(screen.getByText("4×4"));
+ expect(onActiveGridChange).toHaveBeenCalled();
+ });
+
+ test("selecting 'Off' clears the selection (null)", () => {
+ const onFourGroupsChange = jest.fn();
+ renderControls({ fourGroupsCount: 5, onFourGroupsChange });
+
+ const dropdownContainers = screen.getAllByTestId("dropdown-container");
+
+ fireEvent.press(
+ dropdownContainers[1].findByType(
+ require("react-native").TouchableOpacity,
+ ),
+ );
+
+ 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..d6acc95
--- /dev/null
+++ b/app/(Minitool_three)/__tests__/ScatterPlot.test.jsx
@@ -0,0 +1,198 @@
+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",
+ "runOnJS",
+ "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 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(onPointToggle).toHaveBeenCalledWith(0);
+ });
+
+ 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) or the unselected fill (#2563eb).
+ const dataCircles = circles.filter(
+ (c) => c.props.fill === "#2563eb" || c.props.fill === "#f59e0b",
+ );
+ fireEvent.press(dataCircles[2]);
+ expect(onPointToggle).toHaveBeenCalledWith(2);
+ });
+
+ 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..512a0b3
--- /dev/null
+++ b/app/(Minitool_three)/__tests__/minitool_3.test.jsx
@@ -0,0 +1,577 @@
+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:${JSON.stringify(props.selectedPoints)}`}
+ props.onPointToggle?.(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.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:[]",
+ );
+ 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:[]",
+ ),
+ );
+ });
+ });
+
+ 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",
+ );
+ });
+ });
+});