diff --git a/src/blueapi/BlueapiComponents.tsx b/src/blueapi/BlueapiComponents.tsx index c11fae6..e3ef084 100644 --- a/src/blueapi/BlueapiComponents.tsx +++ b/src/blueapi/BlueapiComponents.tsx @@ -30,6 +30,7 @@ type RunPlanButtonProps = { sx?: object; tooltipSx?: object; typographySx?: object; + onSuccess?: () => void | Promise; // Optional callback after plan succeeds }; export function RunPlanButton(props: RunPlanButtonProps) { @@ -63,13 +64,17 @@ export function RunPlanButton(props: RunPlanButtonProps) { planName: props.planName, planParams: params, instrumentSession: instrumentSession, - }).catch((error) => { - setSeverity("error"); - setMsg( - `Failed to run plan ${props.planName}, see console and logs for full error`, - ); - console.log(`${msg}. Reason: ${error}`); - }); + }) + .then(() => { + props.onSuccess?.(); + }) + .catch((error) => { + setSeverity("error"); + setMsg( + `Failed to run plan ${props.planName}, see console and logs for full error`, + ); + console.log(`${msg}. Reason: ${error}`); + }); } catch (error) { setSeverity("error"); setMsg( diff --git a/src/components/OavVideoStream.tsx b/src/components/OavVideoStream.tsx index 585d71f..8e5f439 100644 --- a/src/components/OavVideoStream.tsx +++ b/src/components/OavVideoStream.tsx @@ -116,8 +116,6 @@ function VideoBoxWithOverlay(props: { drawCanvas(canvasRef, props.crosshairX, props.crosshairY); }, [props.crosshairX, props.crosshairY, width, height]); - console.info(); - return ( void; +}; + +export const BeamCenterContext = createContext({ + data: null, + refetch: () => {}, +}); diff --git a/src/context/beamcenter/BeamCenterProvider.test.tsx b/src/context/beamcenter/BeamCenterProvider.test.tsx new file mode 100644 index 0000000..4cca674 --- /dev/null +++ b/src/context/beamcenter/BeamCenterProvider.test.tsx @@ -0,0 +1,80 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { useContext } from "react"; +import "@testing-library/jest-dom/vitest"; +import { BeamCenterProvider } from "./BeamCenterProvider"; +import { BeamCenterContext } from "./BeamCenterContext"; +import { useConfigCall } from "#/config_server/configServer.ts"; +import type { UseQueryResult } from "react-query"; + +vi.mock("#/config_server/configServer.ts", () => ({ + useConfigCall: vi.fn(), +})); + +const TestConsumer = () => { + const value = useContext(BeamCenterContext); + return ( + <> +
{value.data}
+ + + ); +}; + +describe("BeamCenterProvider", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + const mockRefetch = vi.fn(); + const mockQueryResult = { + data: "mock config text", + refetch: mockRefetch, + }; + + beforeEach(() => + vi + .mocked(useConfigCall) + .mockReturnValue( + mockQueryResult as unknown as UseQueryResult, + ), + ); + + it("calls useConfigCall with the correct endpoint", () => { + render( + + + , + ); + + expect(useConfigCall).toHaveBeenCalledWith( + "/dls_sw/i24/software/daq_configuration/domain/display.configuration", + ); + }); + + it("provides the data to consumers via context", () => { + render( + + + , + ); + + expect(screen.getByTestId("context-value")).toHaveTextContent( + "mock config text", + ); + }); + + it("passes refetch function through context and it can be called", () => { + render( + + + , + ); + + fireEvent.click(screen.getByTestId("refetch-button")); + expect(mockRefetch).toHaveBeenCalled(); + }); +}); diff --git a/src/context/beamcenter/BeamCenterProvider.tsx b/src/context/beamcenter/BeamCenterProvider.tsx new file mode 100644 index 0000000..a9db5f2 --- /dev/null +++ b/src/context/beamcenter/BeamCenterProvider.tsx @@ -0,0 +1,16 @@ +import { ReactNode } from "react"; +import { useConfigCall } from "#/config_server/configServer.ts"; +import { BeamCenterContext } from "./BeamCenterContext"; + +const DISPLAY_CONFIG_ENDPOINT = + "/dls_sw/i24/software/daq_configuration/domain/display.configuration"; + +export const BeamCenterProvider = ({ children }: { children: ReactNode }) => { + const beamCenterQuery = useConfigCall(DISPLAY_CONFIG_ENDPOINT); + + return ( + + {children} + + ); +}; diff --git a/src/routes/BeamlineI24.tsx b/src/routes/BeamlineI24.tsx index 40bea1b..c684cbb 100644 --- a/src/routes/BeamlineI24.tsx +++ b/src/routes/BeamlineI24.tsx @@ -2,9 +2,10 @@ import { BeamlineStatsTabPanel } from "#/screens/BeamlineStats.tsx"; import { DetectorMotionTabPanel } from "#/screens/DetectorMotion.tsx"; import { FallbackScreen } from "#/screens/FallbackScreen.tsx"; import { OavMover } from "#/screens/OavMover/OAVStageController.tsx"; +import { BeamCenterProvider } from "#/context/beamcenter/BeamCenterProvider.tsx"; import { Box, Tab, Tabs, useTheme } from "@mui/material"; -import React from "react"; import { ErrorBoundary } from "react-error-boundary"; +import { useState } from "react"; interface TabPanelProps { children?: React.ReactNode; @@ -37,7 +38,7 @@ function CustomTabPanel(props: TabPanelProps) { export function BeamlineI24() { const theme = useTheme(); - const [tab, setTab] = React.useState(0); + const [tab, setTab] = useState(0); const handleChange = (_event: React.SyntheticEvent, newTab: number) => { setTab(newTab); @@ -73,7 +74,9 @@ export function BeamlineI24() { - + + +
diff --git a/src/screens/OavMover/OAVMoveController.tsx b/src/screens/OavMover/OAVMoveController.tsx index 9c139a8..4429f17 100644 --- a/src/screens/OavMover/OAVMoveController.tsx +++ b/src/screens/OavMover/OAVMoveController.tsx @@ -1,4 +1,6 @@ import { RunPlanButton } from "#/blueapi/BlueapiComponents.tsx"; +import { useContext } from "react"; +import { BeamCenterContext } from "#/context/beamcenter/BeamCenterContext.ts"; import { KeyboardDoubleArrowUp, KeyboardArrowUp, @@ -37,7 +39,7 @@ const arrowsScreenSizing = { }, }; -function BlockMove(props: TabPanelProps) { +function BlockMove(props: TabPanelProps & { onMoveSuccess?: () => void }) { if (props.value !== props.index) return null; return ( @@ -48,6 +50,7 @@ function BlockMove(props: TabPanelProps) { planName={"move_block_on_arrow_click"} planParams={{ direction: "up" }} btnVariant="outlined" + onSuccess={props.onMoveSuccess} /> ); } -function NudgeMove(props: TabPanelProps) { +function NudgeMove(props: TabPanelProps & { onMoveSuccess?: () => void }) { if (props.value !== props.index) return null; return ( @@ -87,6 +93,7 @@ function NudgeMove(props: TabPanelProps) { planParams={{ direction: "up", size_of_move: "big" }} btnVariant="outlined" sx={arrowsScreenSizing} + onSuccess={props.onMoveSuccess} /> } @@ -110,6 +119,7 @@ function NudgeMove(props: TabPanelProps) { planParams={{ direction: "left", size_of_move: "small" }} btnVariant="outlined" sx={arrowsScreenSizing} + onSuccess={props.onMoveSuccess} /> } @@ -125,6 +136,7 @@ function NudgeMove(props: TabPanelProps) { planParams={{ direction: "right", size_of_move: "big" }} btnVariant="outlined" sx={arrowsScreenSizing} + onSuccess={props.onMoveSuccess} /> ); } -function WindowMove(props: TabPanelProps) { +function WindowMove(props: TabPanelProps & { onMoveSuccess?: () => void }) { if (props.value !== props.index) return null; return ( @@ -158,6 +172,7 @@ function WindowMove(props: TabPanelProps) { planParams={{ direction: "up", size_of_move: "big" }} btnVariant="outlined" sx={arrowsScreenSizing} + onSuccess={props.onMoveSuccess} /> } @@ -181,6 +198,7 @@ function WindowMove(props: TabPanelProps) { planParams={{ direction: "left", size_of_move: "small" }} btnVariant="outlined" sx={arrowsScreenSizing} + onSuccess={props.onMoveSuccess} /> } @@ -196,6 +215,7 @@ function WindowMove(props: TabPanelProps) { planParams={{ direction: "right", size_of_move: "big" }} btnVariant="outlined" sx={arrowsScreenSizing} + onSuccess={props.onMoveSuccess} /> ); } -function FocusMove(props: TabPanelProps) { +function FocusMove(props: TabPanelProps & { onMoveSuccess?: () => void }) { if (props.value !== props.index) return null; const focus_move = [ { direction: "in", size_of_move: "big", label: "IN x3" }, @@ -252,7 +274,7 @@ function FocusMove(props: TabPanelProps) { export function MoveArrows() { const theme = useTheme(); - + const beamCenterQuery = useContext(BeamCenterContext); const [value, setValue] = useState(0); const isSmall = useMediaQuery(theme.breakpoints.down("xl")); @@ -292,10 +314,28 @@ export function MoveArrows() { - - - - + { + beamCenterQuery?.refetch(); + }} + /> + beamCenterQuery?.refetch()} + /> + beamCenterQuery?.refetch()} + /> + beamCenterQuery?.refetch()} + /> ); } diff --git a/src/screens/OavMover/OAVStageController.test.tsx b/src/screens/OavMover/OAVStageController.test.tsx new file mode 100644 index 0000000..e4acd21 --- /dev/null +++ b/src/screens/OavMover/OAVStageController.test.tsx @@ -0,0 +1,119 @@ +import { renderHook } from "@testing-library/react"; +import { describe, it, vi, beforeEach, expect } from "vitest"; +import { useZoomAndCrosshair } from "./OAVStageController"; +import { useParsedPvConnection } from "#/pv/util.ts"; +import { BeamCenterContext } from "#/context/beamcenter/BeamCenterContext.ts"; +import type { RawValue } from "#/pv/types.ts"; + +vi.mock("#/pv/util.ts", () => ({ + ...vi.importActual("#/pv/util.ts"), + useParsedPvConnection: vi.fn(), + forceString: (x: RawValue | string | number) => String(x), +})); + +type validateZoomTestType = { + zoomLevel: string; + expectedX: number; + expectedY: number; +}; + +describe("useZoomAndCrosshair", () => { + const mockRefetch = vi.fn(); + const mockBeamCenterData = [ + "zoomLevel = 1.0", + "crosshairX = 561", + "crosshairY = 321", + "topLeftX = 611", + "topLeftY = 441", + "bottomRightX = 631", + "bottomRightY = 461", + "zoomLevel = 2.0", + "crosshairX = 562", + "crosshairY = 322", + "topLeftX = 612", + "topLeftY = 442", + "bottomRightX = 632", + "bottomRightY = 462", + "zoomLevel = 3.0", + "crosshairX = 563", + "crosshairY = 323", + "topLeftX = 613", + "topLeftY = 443", + "bottomRightX = 633", + "bottomRightY = 463", + ].join("\n"); + + beforeEach(() => { + vi.mocked(useParsedPvConnection).mockReturnValue("2.0"); + }); + + it.each` + zoomLevel | expectedX | expectedY + ${"1.0"} | ${561} | ${321} + ${"2.0"} | ${562} | ${322} + ${"3.0"} | ${563} | ${323} + `( + "returns ( $expectedX , $expectedY ) for zoom level '$zoomLevel'", + ({ zoomLevel, expectedX, expectedY }: validateZoomTestType) => { + vi.mocked(useParsedPvConnection).mockReturnValue(zoomLevel); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useZoomAndCrosshair(), { wrapper }); + + expect(result.current.crosshairX).toBe(expectedX); + expect(result.current.crosshairY).toBe(expectedY); + }, + ); + + it("returns NaN for crosshair if zoomIndex is not found", () => { + vi.mocked(useParsedPvConnection).mockReturnValue("99.0"); + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useZoomAndCrosshair(), { wrapper }); + + expect(result.current.crosshairX).toBeNaN(); + expect(result.current.crosshairY).toBeNaN(); + }); + + it("returns NaN if beamCenter data is missing", () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + const { result } = renderHook(() => useZoomAndCrosshair(), { wrapper }); + + expect(result.current.crosshairX).toBeNaN(); + expect(result.current.crosshairY).toBeNaN(); + }); + + it("calls refetch when zoom level changes", () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + + {children} + + ); + + renderHook(() => useZoomAndCrosshair(), { wrapper }); + vi.mocked(useParsedPvConnection).mockReturnValue("3.0"); + expect(mockRefetch).toHaveBeenCalled(); + }); +}); diff --git a/src/screens/OavMover/OAVStageController.tsx b/src/screens/OavMover/OAVStageController.tsx index 0098340..0cf4037 100644 --- a/src/screens/OavMover/OAVStageController.tsx +++ b/src/screens/OavMover/OAVStageController.tsx @@ -1,25 +1,33 @@ import { Grid2, useTheme } from "@mui/material"; +import { useContext, useRef } from "react"; import { OAVSideBar } from "./OAVSideBar"; import { submitAndRunPlanImmediately } from "#/blueapi/blueapi.ts"; import { readVisitFromPv, parseInstrumentSession } from "#/blueapi/visit.ts"; import { OavVideoStream } from "#/components/OavVideoStream.tsx"; -import { useConfigCall } from "#/config_server/configServer.ts"; import { forceString, useParsedPvConnection } from "#/pv/util.ts"; import { ZoomLevels } from "#/pv/enumPvValues.ts"; -import { useMemo } from "react"; +import { useMemo, useEffect } from "react"; +import { BeamCenterContext } from "#/context/beamcenter/BeamCenterContext.ts"; -const DISPLAY_CONFIG_ENDPOINT = - "/dls_sw/i24/software/daq_configuration/domain/display.configuration"; +const ZOOM_PV = "ca://BL24I-EA-OAV-01:FZOOM:MP:SELECT"; +const BEAM_CENTER_LINES_PER_ZOOM = 7; -export function OavMover() { - const beamCenterQuery = useConfigCall(DISPLAY_CONFIG_ENDPOINT); +export function useZoomAndCrosshair() { + const beamCenterQuery = useContext(BeamCenterContext); const currentZoomValue = String( useParsedPvConnection({ - pv: "ca://BL24I-EA-OAV-01:FZOOM:MP:SELECT", + pv: ZOOM_PV, label: "zoom-level", transformValue: forceString, }), ); + + const beamCenterQueryRef = useRef(beamCenterQuery); + + useEffect(() => { + beamCenterQueryRef.current.refetch(); + }, [currentZoomValue]); + const zoomIndex = ZoomLevels.findIndex( (element: string) => element == currentZoomValue, ); @@ -30,8 +38,8 @@ export function OavMover() { } const lines = beamCenterQuery.data.split("\n"); - const xLine = lines[zoomIndex * 7 + 1]; - const yLine = lines[zoomIndex * 7 + 2]; + const xLine = lines[zoomIndex * BEAM_CENTER_LINES_PER_ZOOM + 1]; + const yLine = lines[zoomIndex * BEAM_CENTER_LINES_PER_ZOOM + 2]; if (!xLine || !yLine) { return [NaN, NaN]; @@ -40,10 +48,17 @@ export function OavMover() { return [Number(xLine.split(" ")[2]), Number(yLine.split(" ")[2])]; }, [beamCenterQuery.data, zoomIndex]); + return { crosshairX, crosshairY }; +} + +export function OavMover() { + const { crosshairX, crosshairY } = useZoomAndCrosshair(); + const theme = useTheme(); const bgColor = theme.palette.background.paper; const fullVisit = readVisitFromPv(); + const beamCenterQuery = useContext(BeamCenterContext); function onCoordClick(x: number, y: number) { submitAndRunPlanImmediately({ @@ -52,9 +67,11 @@ export function OavMover() { instrumentSession: parseInstrumentSession(fullVisit), }).catch((error) => { console.log( - `Failed to run plan , see console and logs for full error. Reason: ${error}`, + `Failed to run plan, see console and logs for full error. Reason: ${error}`, ); }); + + beamCenterQuery.refetch(); } return (