Skip to content

Commit 902f99d

Browse files
authored
Merge pull request #126 from DiamondLightSource/125-add-meter-widget
Add meter widget
2 parents 5dcdc8d + d947b62 commit 902f99d

File tree

9 files changed

+1884
-15
lines changed

9 files changed

+1884
-15
lines changed

package-lock.json

Lines changed: 805 additions & 13 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
"jsdom": "^25.0.1",
6060
"loglevel": "^1.9.2",
6161
"prettier": "^3.3.3",
62+
"react-gauge-component": "^1.2.64",
6263
"react-id-generator": "^3.0.2",
6364
"react-test-renderer": "^18.3.1",
6465
"react-tiny-popover": "^8.1.2",

src/ui/widgets/EmbeddedDisplay/bobParser.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ const BOB_WIDGET_MAPPING: { [key: string]: any } = {
5656
rectangle: "shape",
5757
tank: "tank",
5858
thermometer: "thermometer",
59+
meter: "meter",
5960
choice: "choicebutton",
6061
scaledslider: "slidecontrol",
6162
symbol: "symbol"
@@ -85,6 +86,7 @@ export const WIDGET_DEFAULT_SIZES: { [key: string]: [number, number] } = {
8586
rectangle: [100, 20],
8687
tank: [150, 200],
8788
thermometer: [40, 160],
89+
meter: [240, 120],
8890
scaledslider: [400, 55],
8991
symbol: [100, 100]
9092
};
@@ -381,6 +383,7 @@ export function parseBob(
381383
symbols: ["symbols", bobParseSymbols],
382384
initialIndex: ["initial_index", bobParseNumber],
383385
showIndex: ["show_index", opiParseBoolean],
386+
showValue: ["show_value", opiParseBoolean],
384387
fallbackSymbol: ["fallback_symbol", opiParseString],
385388
rotation: ["rotation", bobParseNumber],
386389
styleOpt: ["style", bobParseNumber],
@@ -401,7 +404,9 @@ export function parseBob(
401404
majorTickStepHint: ["major_tick_step_hint", bobParseNumber],
402405
maximum: ["maximum", bobParseNumber],
403406
minimum: ["minimum", bobParseNumber],
404-
emptyColor: ["empty_color", opiParseColor]
407+
format: ["format", bobParseNumber],
408+
emptyColor: ["empty_color", opiParseColor],
409+
needleColor: ["needle_color", opiParseColor]
405410
};
406411

407412
const complexParsers = {

src/ui/widgets/EmbeddedDisplay/opiParser.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ const OPI_WIDGET_MAPPING: { [key: string]: any } = {
7373
"org.csstudio.opibuilder.widgets.progressbar": "progressbar",
7474
"org.csstudio.opibuilder.widgets.tank": "tank",
7575
"org.csstudio.opibuilder.widgets.thermometer": "thermometer",
76+
"org.csstudio.opibuilder.widgets.tmeter": "meter",
7677
"org.csstudio.opibuilder.widgets.LED": "led",
7778
"org.csstudio.opibuilder.widgets.Image": "image",
7879
"org.csstudio.opibuilder.widgets.edm.symbolwidget": "pngsymbol",
@@ -642,11 +643,13 @@ export const OPI_SIMPLE_PARSERS: ParserDict = {
642643
onColor: ["on_color", opiParseColor],
643644
offColor: ["off_color", opiParseColor],
644645
fillColor: ["fill_color", opiParseColor],
646+
needleColor: ["needle_color", opiParseColor],
645647
precision: ["precision", opiParseNumber],
646648
formatType: ["format_type", opiParseFormatType],
647649
precisionFromPv: ["precision_from_pv", opiParseBoolean],
648650
visible: ["visible", opiParseBoolean],
649651
showUnits: ["show_units", opiParseBoolean],
652+
showValue: ["show_value_label", opiParseBoolean],
650653
scaleVisible: ["scale_visible", opiParseBoolean],
651654
transparent: ["transparent", opiParseBoolean],
652655
horizontal: ["horizontal", opiParseBoolean],
@@ -713,7 +716,9 @@ export const OPI_SIMPLE_PARSERS: ParserDict = {
713716
selectedColor: ["selected_color", opiParseColor],
714717
enabled: ["enabled", opiParseBoolean],
715718
resize: ["resize_behaviour", opiParseResizing],
716-
labelsFromPv: ["labels_from_pv", opiParseBoolean]
719+
labelsFromPv: ["labels_from_pv", opiParseBoolean],
720+
limitsFromPv: ["limits_from_pv", opiParseBoolean],
721+
format: ["format_type", opiParseNumber]
717722
};
718723

719724
/**
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import React from "react";
3+
import { render, screen } from "@testing-library/react";
4+
import { MeterComponent } from "./meter";
5+
import { Color } from "../../../types/color";
6+
import { NumberFormatEnum } from "./meterUtilities";
7+
import * as meterUtilities from "./meterUtilities";
8+
import { DType, Font } from "../../../types";
9+
10+
vi.mock("react-gauge-component", () => ({
11+
GaugeComponent: vi.fn(({ value, minValue, maxValue, labels, style }) => (
12+
<div
13+
data-testid="gauge-component"
14+
data-value={value}
15+
data-min={minValue}
16+
data-max={maxValue}
17+
data-style={JSON.stringify(style)}
18+
>
19+
<div
20+
data-testid="value-label"
21+
data-hide={labels.valueLabel.hide}
22+
data-font-family={labels.valueLabel.style.fontFamily}
23+
></div>
24+
</div>
25+
))
26+
}));
27+
28+
vi.mock("./meterUtilities", async () => {
29+
const actual = await vi.importActual("./meterUtilities");
30+
return {
31+
...actual,
32+
formatValue: vi.fn(
33+
(value, format, precision, units, showUnits) => () => `${value}`
34+
),
35+
buildSubArcs: vi.fn(() => [{ color: "red", start: 0, end: 100 }]),
36+
createIntervals: vi.fn(() => [0, 25, 50, 75, 100]),
37+
convertInfAndNanToUndefined: vi.fn(val => val)
38+
};
39+
});
40+
41+
describe("MeterComponent", () => {
42+
const defaultProps = {
43+
connected: false,
44+
readonly: true,
45+
pvName: "PV:Test",
46+
value: {
47+
getDoubleValue: () => 50,
48+
display: {
49+
units: "kW",
50+
controlRange: { min: 0, max: 100 },
51+
alarmRange: { min: 80, max: 100 },
52+
warningRange: { min: 60, max: 80 }
53+
}
54+
} as Partial<DType> as DType,
55+
foregroundColor: Color.fromRgba(0, 0, 0, 1),
56+
needleColor: Color.fromRgba(255, 5, 7, 1),
57+
backgroundColor: Color.fromRgba(250, 250, 250, 1)
58+
};
59+
60+
beforeEach(() => {
61+
vi.clearAllMocks();
62+
});
63+
64+
it("renders with default props", () => {
65+
render(<MeterComponent {...defaultProps} />);
66+
67+
const gaugeComponent = screen.getByTestId("gauge-component");
68+
expect(gaugeComponent).toBeInTheDocument();
69+
expect(gaugeComponent.getAttribute("data-value")).toBe("50");
70+
expect(gaugeComponent.getAttribute("data-min")).toBe("0");
71+
expect(gaugeComponent.getAttribute("data-max")).toBe("100");
72+
});
73+
74+
it("uses custom min/max values when limitsFromPv is false", () => {
75+
render(
76+
<MeterComponent
77+
{...defaultProps}
78+
minimum={-50}
79+
maximum={150}
80+
limitsFromPv={false}
81+
/>
82+
);
83+
84+
const gaugeComponent = screen.getByTestId("gauge-component");
85+
expect(gaugeComponent.getAttribute("data-min")).toBe("-50");
86+
expect(gaugeComponent.getAttribute("data-max")).toBe("150");
87+
});
88+
89+
it("uses PV limits when limitsFromPv is true", () => {
90+
render(
91+
<MeterComponent
92+
{...defaultProps}
93+
minimum={-50}
94+
maximum={150}
95+
limitsFromPv={true}
96+
/>
97+
);
98+
99+
const gaugeComponent = screen.getByTestId("gauge-component");
100+
expect(gaugeComponent.getAttribute("data-min")).toBe("0"); // From controlRange.min
101+
expect(gaugeComponent.getAttribute("data-max")).toBe("100"); // From controlRange.max
102+
});
103+
104+
it("uses transparent background when transparent is true", () => {
105+
render(<MeterComponent {...defaultProps} transparent={true} />);
106+
107+
const box = screen.getByTestId("gauge-component").parentElement;
108+
expect(box).toHaveStyle("background-color: rgba(0, 0, 0, 0)");
109+
});
110+
111+
it("hides value when showValue is false", () => {
112+
render(<MeterComponent {...defaultProps} showValue={false} />);
113+
114+
const valueLabel = screen.getByTestId("value-label");
115+
expect(valueLabel.getAttribute("data-hide")).toBe("true");
116+
});
117+
118+
it("calls formatValue with correct parameters", () => {
119+
render(
120+
<MeterComponent
121+
{...defaultProps}
122+
format={NumberFormatEnum.Exponential}
123+
precision={2}
124+
showUnits={true}
125+
/>
126+
);
127+
128+
expect(meterUtilities.formatValue).toHaveBeenCalledWith(
129+
50,
130+
NumberFormatEnum.Exponential,
131+
2,
132+
"kW",
133+
true
134+
);
135+
});
136+
137+
it("calls buildSubArcs with correct parameters", () => {
138+
render(<MeterComponent {...defaultProps} />);
139+
140+
expect(meterUtilities.buildSubArcs).toHaveBeenCalledWith(
141+
"rgba(0,0,0,1)",
142+
0,
143+
100,
144+
80,
145+
60,
146+
80,
147+
100
148+
);
149+
});
150+
151+
it("handles missing PV value gracefully", () => {
152+
render(<MeterComponent {...defaultProps} value={undefined} />);
153+
154+
const gaugeComponent = screen.getByTestId("gauge-component");
155+
expect(gaugeComponent.getAttribute("data-value")).toBe("0");
156+
});
157+
158+
it("scales width correctly based on height/width ratio", () => {
159+
render(<MeterComponent {...defaultProps} height={100} width={300} />);
160+
161+
const gauge = screen.getByTestId("gauge-component");
162+
expect(JSON.parse(gauge.getAttribute("data-style") ?? "{}").width).toBe(
163+
190
164+
); // scaled width
165+
});
166+
167+
it("uses 100% parent box width when width/height ratio is greater than 1.9", () => {
168+
render(<MeterComponent {...defaultProps} height={100} width={300} />);
169+
170+
const box = screen.getByTestId("gauge-component").parentElement;
171+
expect(box).toHaveStyle("width: 100%");
172+
});
173+
174+
it("uses width when width/height ratio is less than 1.9", () => {
175+
render(<MeterComponent {...defaultProps} height={100} width={150} />);
176+
177+
const gauge = screen.getByTestId("gauge-component");
178+
expect(JSON.parse(gauge.getAttribute("data-style") ?? "{}").width).toBe(
179+
150
180+
); // specified width
181+
});
182+
183+
it("uses 100% parent box width when width/height ratio is less than 1.9", () => {
184+
render(<MeterComponent {...defaultProps} height={100} width={150} />);
185+
186+
const box = screen.getByTestId("gauge-component").parentElement;
187+
expect(box).toHaveStyle("width: 100%");
188+
});
189+
190+
it("applies font styles correctly", () => {
191+
const font = {
192+
css: () => ({ fontFamily: "Arial" })
193+
} as Partial<Font> as Font;
194+
195+
render(<MeterComponent {...defaultProps} font={font} />);
196+
197+
const valueLabel = screen.getByTestId("value-label");
198+
199+
const labelStyle = valueLabel.getAttribute("data-font-family");
200+
expect(labelStyle).toBe("Arial");
201+
});
202+
});

0 commit comments

Comments
 (0)