Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/p99/helm/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,11 @@ ui-base:
image:
repository: ghcr.io/diamondlightsource/atlas/p99
tag: # Updated by CI job with latest release version

upstreams:
- id: blueapi
path: /api/
rewriteTarget: /
target:
external:
uri: https://p99-blueapi.diamond.ac.uk/
7 changes: 6 additions & 1 deletion apps/p99/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,17 @@
"coverage": "vitest run --coverage"
},
"dependencies": {
"@atlas/blueapi": "workspace:*",
"@atlas/blueapi-query": "workspace:*",
"@diamondlightsource/sci-react-ui": "^0.2.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^6.5.0",
"@mui/material": "<7.0.0",
"react-router-dom": "^7.7.1"
"@tanstack/react-query": "^5.90.21",
"@testing-library/react": "^15.0.7",
"react-router-dom": "^7.7.1",
"vite-plugin-relay": "^2.1.0"
},
"devDependencies": {
"@atlas/vitest-conf": "workspace:*",
Expand Down
72 changes: 72 additions & 0 deletions apps/p99/src/components/DevicePanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { render, screen } from "@atlas/vitest-conf";
import { DiamondTheme, ThemeProvider } from "@diamondlightsource/sci-react-ui";
import { DevicePanel } from "./DevicePanel";
import { describe, it, expect } from "vitest";

const mockDevicesData = {
devices: [
{ name: "detector-1", type: "detector" },
{ name: "motor-x", type: "motor" },
{ name: "motor-y", type: "motor" },
],
};

describe("DevicePanel", () => {
it("renders the devices panel with title", () => {
render(
<ThemeProvider theme={DiamondTheme} defaultMode="light">
<DevicePanel devicesData={mockDevicesData} />
</ThemeProvider>,
);

expect(screen.getByText(/Connected Devices/)).toBeInTheDocument();
});

it("displays the correct device count", () => {
render(
<ThemeProvider theme={DiamondTheme} defaultMode="light">
<DevicePanel devicesData={mockDevicesData} />
</ThemeProvider>,
);

expect(screen.getByText(/Connected Devices \(3\)/)).toBeInTheDocument();
});

it("renders all devices as chips", () => {
render(
<ThemeProvider theme={DiamondTheme} defaultMode="light">
<DevicePanel devicesData={mockDevicesData} />
</ThemeProvider>,
);

expect(screen.getByText("detector-1")).toBeInTheDocument();
expect(screen.getByText("motor-x")).toBeInTheDocument();
expect(screen.getByText("motor-y")).toBeInTheDocument();
});

it("shows empty state when no devices are present", () => {
render(
<ThemeProvider theme={DiamondTheme} defaultMode="light">
<DevicePanel devicesData={{ devices: [] }} />
</ThemeProvider>,
);

expect(screen.getByText(/Connected Devices \(0\)/)).toBeInTheDocument();
expect(
screen.getByText(/No devices detected on the worker/),
).toBeInTheDocument();
});

it("handles undefined devicesData gracefully", () => {
render(
<ThemeProvider theme={DiamondTheme} defaultMode="light">
<DevicePanel devicesData={undefined} />
</ThemeProvider>,
);

expect(screen.getByText(/Connected Devices \(0\)/)).toBeInTheDocument();
expect(
screen.getByText(/No devices detected on the worker/),
).toBeInTheDocument();
});
});
85 changes: 85 additions & 0 deletions apps/p99/src/components/DevicePanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import {
Accordion,
AccordionSummary,
AccordionDetails,
Typography,
Stack,
Chip,
Box,
} from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import SettingsInputComponentIcon from "@mui/icons-material/SettingsInputComponent";

interface DeviceInfo {
name: string;
[key: string]: unknown;
}

interface DevicePanelProps {
devicesData:
| {
devices?: DeviceInfo[];
}
| undefined;
}

export function DevicePanel({ devicesData }: DevicePanelProps) {
const devices = devicesData?.devices || [];

return (
<Box sx={{ width: "100%" }}>
<Accordion
defaultExpanded
elevation={2}
sx={{
border: "1px solid",
borderColor: "divider",
borderRadius: "8px !important",
bgcolor: "background.paper",
}}
>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Stack direction="row" spacing={1} alignItems="center">
<SettingsInputComponentIcon color="primary" fontSize="small" />
<Typography variant="subtitle1" fontWeight="bold">
Connected Devices ({devices.length})
</Typography>
</Stack>
</AccordionSummary>
<AccordionDetails>
{devices.length === 0 ? (
<Typography variant="body2" color="text.disabled">
No devices detected on the worker.
</Typography>
) : (
<Box
sx={{
display: "flex",
flexWrap: "wrap",
gap: 1,
maxHeight: { xs: "150px", md: "calc(100vh - 220px)" },
overflowY: "auto",
p: 0.5,
}}
>
{devices.map((device: DeviceInfo) => (
<Chip
key={device.name}
label={device.name}
variant="outlined"
size="small"
color="primary"
sx={{
fontFamily: "monospace",
fontWeight: 500,
bgcolor: "grey.50",
}}
/>
))}
</Box>
)}
</AccordionDetails>
</Accordion>
</Box>
);
}
159 changes: 159 additions & 0 deletions apps/p99/src/components/PlanCard.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { render, screen, waitFor } from "@atlas/vitest-conf";
import { fireEvent } from "@testing-library/react";
import { DiamondTheme, ThemeProvider } from "@diamondlightsource/sci-react-ui";
import { PlanCard } from "./PlanCard";
import type { Plan } from "@atlas/blueapi";
import { vi, describe, it, expect, beforeEach } from "vitest";

const mockUseGetWorkerState = vi.fn(() => ({ data: "IDLE" }));
const mockSubmitTask = {
mutateAsync: vi.fn(() => Promise.resolve({ task_id: "task-1" })),
};
const mockStartTask = { mutateAsync: vi.fn(() => Promise.resolve()) };

vi.mock("@atlas/blueapi-query", () => ({
useGetWorkerState: () => mockUseGetWorkerState(),
useSubmitTask: () => mockSubmitTask,
useSetActiveTask: () => mockStartTask,
}));

const mockPlan: Plan = {
name: "test-plan",
description: "Test plan description\n\nParameters:\nSome params",
schema: {
properties: {
param1: {
type: "string",
title: "Parameter 1",
description: "First parameter",
},
param2: {
type: "number",
title: "Parameter 2",
description: "Second parameter",
},
},
required: ["param1"],
},
};

describe("PlanCard", () => {
const mockOnSuccess = vi.fn();
const mockOnError = vi.fn();

beforeEach(() => {
vi.clearAllMocks();
mockUseGetWorkerState.mockReset();
mockUseGetWorkerState.mockReturnValue({ data: "IDLE" });
mockSubmitTask.mutateAsync.mockClear();
mockStartTask.mutateAsync.mockClear();
});

it("renders the plan name and description", () => {
render(
<ThemeProvider theme={DiamondTheme} defaultMode="light">
<PlanCard
plan={mockPlan}
instrumentSession="test-session"
onSuccess={mockOnSuccess}
onError={mockOnError}
/>
</ThemeProvider>,
);

expect(screen.getByText("test-plan")).toBeInTheDocument();
expect(screen.getByText(/Test plan description/)).toBeInTheDocument();
});

it("displays Python chip", () => {
render(
<ThemeProvider theme={DiamondTheme} defaultMode="light">
<PlanCard
plan={mockPlan}
instrumentSession="test-session"
onSuccess={mockOnSuccess}
onError={mockOnError}
/>
</ThemeProvider>,
);

expect(screen.getByText("Python")).toBeInTheDocument();
});

it("renders accordion with configure details", async () => {
render(
<ThemeProvider theme={DiamondTheme} defaultMode="light">
<PlanCard
plan={mockPlan}
instrumentSession="test-session"
onSuccess={mockOnSuccess}
onError={mockOnError}
/>
</ThemeProvider>,
);

// Click accordion to expand
const accordionButton = screen.getByRole("button", {
name: /Configure & View Details/i,
});
fireEvent.click(accordionButton);

await waitFor(() => {
expect(screen.getByText(/Protocol Documentation/)).toBeInTheDocument();
});
});

it("enables submit button when worker is idle", () => {
render(
<ThemeProvider theme={DiamondTheme} defaultMode="light">
<PlanCard
plan={mockPlan}
instrumentSession="test-session"
onSuccess={mockOnSuccess}
onError={mockOnError}
/>
</ThemeProvider>,
);

const buttons = screen.getAllByRole("button");
const runButton = buttons.find(btn => btn.textContent?.includes("Run"));
expect(runButton).not.toBeDisabled();
});

it("disables submit button when worker is busy", () => {
mockUseGetWorkerState.mockReturnValue({ data: "RUNNING" });

render(
<ThemeProvider theme={DiamondTheme} defaultMode="light">
<PlanCard
plan={mockPlan}
instrumentSession="test-session"
onSuccess={mockOnSuccess}
onError={mockOnError}
/>
</ThemeProvider>,
);

const buttons = screen.getAllByRole("button");
const runButton = buttons.find(btn => btn.textContent?.includes("Run"));
expect(runButton).toBeDisabled();
});

it("shows configure & view details button", () => {
render(
<ThemeProvider theme={DiamondTheme} defaultMode="light">
<PlanCard
plan={mockPlan}
instrumentSession="test-session"
onSuccess={mockOnSuccess}
onError={mockOnError}
/>
</ThemeProvider>,
);

const configButton = screen.getByRole("button", {
name: /Configure & View Details/i,
});
expect(configButton).toBeInTheDocument();
});
});
Loading
Loading