pamoja_dashboard/
catalog.rs1use std::collections::BTreeMap;
15
16use serde::Serialize;
17
18use pamoja_profile::{LocalizedText, Presentation, Profile, Scope, Theme};
19
20#[derive(Clone, Debug, Serialize)]
25#[serde(rename_all = "camelCase")]
26struct Preset {
27 id: String,
28 key: String,
29 unit: String,
30 viz: String,
31 label: String,
32 #[serde(skip_serializing_if = "Option::is_none")]
33 labels: Option<BTreeMap<String, String>>,
34 scope: Scope,
35 #[serde(skip_serializing_if = "Option::is_none")]
36 band: Option<[f32; 2]>,
37 #[serde(skip_serializing_if = "is_false")]
38 stat: bool,
39 #[serde(skip_serializing_if = "is_false")]
40 span: bool,
41 #[serde(skip_serializing_if = "Option::is_none")]
42 value: Option<f32>,
43 #[serde(skip_serializing_if = "Option::is_none")]
44 state: Option<String>,
45}
46
47#[derive(Clone, Debug, Serialize)]
56#[serde(rename_all = "camelCase")]
57pub struct Catalog {
58 sensor_presets: Vec<Preset>,
59 #[serde(skip_serializing_if = "Option::is_none")]
60 theme: Option<Theme>,
61 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
62 messages: BTreeMap<String, LocalizedText>,
63 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
64 site_positions: BTreeMap<String, [f32; 2]>,
65}
66
67impl Catalog {
68 pub fn from_profiles(profiles: &[&Profile]) -> Self {
82 let presentations: Vec<&Presentation> = profiles
83 .iter()
84 .filter_map(|profile| profile.presentation.as_ref())
85 .collect();
86 Self::from_presentations(&presentations)
87 }
88
89 pub fn from_presentations(presentations: &[&Presentation]) -> Self {
103 let mut sensor_presets: Vec<Preset> = Vec::new();
104 let mut theme: Option<Theme> = None;
105 let mut messages: BTreeMap<String, LocalizedText> = BTreeMap::new();
106 for presentation in presentations {
107 if theme.is_none() {
108 theme = presentation.theme.clone();
109 }
110 for (key, text) in &presentation.messages {
111 messages.entry(key.clone()).or_insert_with(|| text.clone());
112 }
113 for element in &presentation.elements {
114 if sensor_presets.iter().any(|p| p.key == element.key) {
115 continue;
116 }
117 sensor_presets.push(Preset {
118 id: element.key.clone(),
119 key: element.key.clone(),
120 unit: element.unit.clone(),
121 viz: element.viz.kind().to_owned(),
122 label: element.label.clone(),
123 labels: element.labels.clone(),
124 scope: element.scope.clone(),
125 band: element.band,
126 stat: element.stat,
127 span: element.span,
128 value: element.value,
129 state: element.state.clone(),
130 });
131 }
132 }
133 Self {
134 sensor_presets,
135 theme,
136 messages,
137 site_positions: BTreeMap::new(),
138 }
139 }
140
141 pub fn with_site_position(mut self, group: impl Into<String>, x: f32, y: f32) -> Self {
153 self.site_positions.insert(group.into(), [x, y]);
154 self
155 }
156
157 pub fn is_empty(&self) -> bool {
164 self.sensor_presets.is_empty()
165 && self.theme.is_none()
166 && self.messages.is_empty()
167 && self.site_positions.is_empty()
168 }
169
170 pub fn to_json(&self) -> Result<String, serde_json::Error> {
181 serde_json::to_string(self)
182 }
183}
184
185fn is_false(value: &bool) -> bool {
186 !*value
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192 use pamoja_profile::{ElementSpec, Presentation, Viz};
193
194 fn water_profile() -> Profile {
195 Profile::well_level().with_presentation(
196 Presentation::new()
197 .with_element(
198 ElementSpec::new("water_turbidity", "ntu", "Turbidity", Viz::Gauge)
199 .with_band(0.0, 5.0)
200 .on(Scope::Links(vec!["mesh".into()])),
201 )
202 .with_element(
203 ElementSpec::new("packets_dropped", "count", "Packets dropped", Viz::Count)
204 .as_stat(),
205 )
206 .with_message("event.filter_clog", "Filter clogged"),
207 )
208 }
209
210 #[test]
211 fn from_profiles_flattens_every_element_to_a_preset() {
212 let profile = water_profile();
213 let catalog = Catalog::from_profiles(&[&profile]);
214 assert_eq!(catalog.sensor_presets.len(), 2);
215 let turbidity = &catalog.sensor_presets[0];
216 assert_eq!(turbidity.viz, "radial");
217 assert_eq!(turbidity.band, Some([0.0, 5.0]));
218 assert!(!turbidity.stat);
219 }
220
221 #[test]
222 fn duplicate_keys_across_profiles_are_kept_once() {
223 let profile = water_profile();
224 let catalog = Catalog::from_profiles(&[&profile, &profile]);
225 assert_eq!(
226 catalog.sensor_presets.len(),
227 2,
228 "the second copy is skipped"
229 );
230 }
231
232 #[test]
233 fn a_profile_without_presentation_yields_an_empty_catalog() {
234 let mut plain = Profile::well_level();
237 plain.presentation = None;
238 assert!(Catalog::from_profiles(&[&plain]).is_empty());
239 }
240
241 #[test]
242 fn a_presentation_alone_and_map_positions_make_a_catalog() {
243 let presentation = Presentation::new().with_element(ElementSpec::new(
244 "pump_speed",
245 "percent",
246 "Pump speed",
247 Viz::Bar,
248 ));
249 let catalog = Catalog::from_presentations(&[&presentation])
250 .with_site_position("farm-node", 0.3, 0.7)
251 .with_site_position("__gateway", 0.5, 0.5);
252 assert!(!catalog.is_empty());
253 let json = catalog.to_json().expect("serialize");
254 assert!(
255 json.contains("\"sitePositions\":{\"__gateway\":[0.5,0.5],\"farm-node\":[0.3,0.7]}")
256 );
257 assert!(Catalog::from_presentations(&[])
258 .with_site_position("hub", 0.1, 0.1)
259 .to_json()
260 .expect("serialize")
261 .contains("sitePositions"));
262 assert!(Catalog::from_presentations(&[]).is_empty());
263 }
264
265 #[test]
266 fn the_json_uses_the_page_catalog_shape() {
267 let profile = water_profile();
268 let json = Catalog::from_profiles(&[&profile])
269 .to_json()
270 .expect("serialize");
271 assert!(json.contains("\"sensorPresets\""));
272 assert!(json.contains("\"viz\":\"count\""));
273 assert!(json.contains("\"scope\":{\"links\":[\"mesh\"]}"));
275 assert!(json.contains("\"scope\":\"always\""));
276 assert!(json.contains("\"messages\":{\"event.filter_clog\":\"Filter clogged\"}"));
278 }
279}