pamoja_dashboard/
catalog.rs1use std::collections::BTreeMap;
15
16use serde::Serialize;
17
18use pamoja_profile::{LocalizedText, 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)]
54#[serde(rename_all = "camelCase")]
55pub struct Catalog {
56 sensor_presets: Vec<Preset>,
57 #[serde(skip_serializing_if = "Option::is_none")]
58 theme: Option<Theme>,
59 #[serde(skip_serializing_if = "BTreeMap::is_empty")]
60 messages: BTreeMap<String, LocalizedText>,
61}
62
63impl Catalog {
64 pub fn from_profiles(profiles: &[&Profile]) -> Self {
78 let mut sensor_presets: Vec<Preset> = Vec::new();
79 let mut theme: Option<Theme> = None;
80 let mut messages: BTreeMap<String, LocalizedText> = BTreeMap::new();
81 for profile in profiles {
82 let Some(presentation) = &profile.presentation else {
83 continue;
84 };
85 if theme.is_none() {
86 theme = presentation.theme.clone();
87 }
88 for (key, text) in &presentation.messages {
89 messages.entry(key.clone()).or_insert_with(|| text.clone());
90 }
91 for element in &presentation.elements {
92 if sensor_presets.iter().any(|p| p.key == element.key) {
93 continue;
94 }
95 sensor_presets.push(Preset {
96 id: element.key.clone(),
97 key: element.key.clone(),
98 unit: element.unit.clone(),
99 viz: element.viz.kind().to_owned(),
100 label: element.label.clone(),
101 labels: element.labels.clone(),
102 scope: element.scope.clone(),
103 band: element.band,
104 stat: element.stat,
105 span: element.span,
106 value: element.value,
107 state: element.state.clone(),
108 });
109 }
110 }
111 Self {
112 sensor_presets,
113 theme,
114 messages,
115 }
116 }
117
118 pub fn is_empty(&self) -> bool {
125 self.sensor_presets.is_empty() && self.theme.is_none() && self.messages.is_empty()
126 }
127
128 pub fn to_json(&self) -> Result<String, serde_json::Error> {
139 serde_json::to_string(self)
140 }
141}
142
143fn is_false(value: &bool) -> bool {
144 !*value
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150 use pamoja_profile::{ElementSpec, Presentation, Viz};
151
152 fn water_profile() -> Profile {
153 Profile::well_level().with_presentation(
154 Presentation::new()
155 .with_element(
156 ElementSpec::new("water_turbidity", "ntu", "Turbidity", Viz::Gauge)
157 .with_band(0.0, 5.0)
158 .on(Scope::Links(vec!["mesh".into()])),
159 )
160 .with_element(
161 ElementSpec::new("packets_dropped", "count", "Packets dropped", Viz::Count)
162 .as_stat(),
163 )
164 .with_message("event.filter_clog", "Filter clogged"),
165 )
166 }
167
168 #[test]
169 fn from_profiles_flattens_every_element_to_a_preset() {
170 let profile = water_profile();
171 let catalog = Catalog::from_profiles(&[&profile]);
172 assert_eq!(catalog.sensor_presets.len(), 2);
173 let turbidity = &catalog.sensor_presets[0];
174 assert_eq!(turbidity.viz, "radial");
175 assert_eq!(turbidity.band, Some([0.0, 5.0]));
176 assert!(!turbidity.stat);
177 }
178
179 #[test]
180 fn duplicate_keys_across_profiles_are_kept_once() {
181 let profile = water_profile();
182 let catalog = Catalog::from_profiles(&[&profile, &profile]);
183 assert_eq!(
184 catalog.sensor_presets.len(),
185 2,
186 "the second copy is skipped"
187 );
188 }
189
190 #[test]
191 fn a_profile_without_presentation_yields_an_empty_catalog() {
192 let plain = Profile::well_level();
193 assert!(Catalog::from_profiles(&[&plain]).is_empty());
194 }
195
196 #[test]
197 fn the_json_uses_the_page_catalog_shape() {
198 let profile = water_profile();
199 let json = Catalog::from_profiles(&[&profile])
200 .to_json()
201 .expect("serialize");
202 assert!(json.contains("\"sensorPresets\""));
203 assert!(json.contains("\"viz\":\"count\""));
204 assert!(json.contains("\"scope\":{\"links\":[\"mesh\"]}"));
206 assert!(json.contains("\"scope\":\"always\""));
207 assert!(json.contains("\"messages\":{\"event.filter_clog\":\"Filter clogged\"}"));
209 }
210}