use std::collections::BTreeMap;
use serde::Serialize;
use pamoja_profile::{LocalizedText, Profile, Scope, Theme};
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
struct Preset {
id: String,
key: String,
unit: String,
viz: String,
label: String,
#[serde(skip_serializing_if = "Option::is_none")]
labels: Option<BTreeMap<String, String>>,
scope: Scope,
#[serde(skip_serializing_if = "Option::is_none")]
band: Option<[f32; 2]>,
#[serde(skip_serializing_if = "is_false")]
stat: bool,
#[serde(skip_serializing_if = "is_false")]
span: bool,
#[serde(skip_serializing_if = "Option::is_none")]
value: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
state: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Catalog {
sensor_presets: Vec<Preset>,
#[serde(skip_serializing_if = "Option::is_none")]
theme: Option<Theme>,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
messages: BTreeMap<String, LocalizedText>,
}
impl Catalog {
pub fn from_profiles(profiles: &[&Profile]) -> Self {
let mut sensor_presets: Vec<Preset> = Vec::new();
let mut theme: Option<Theme> = None;
let mut messages: BTreeMap<String, LocalizedText> = BTreeMap::new();
for profile in profiles {
let Some(presentation) = &profile.presentation else {
continue;
};
if theme.is_none() {
theme = presentation.theme.clone();
}
for (key, text) in &presentation.messages {
messages.entry(key.clone()).or_insert_with(|| text.clone());
}
for element in &presentation.elements {
if sensor_presets.iter().any(|p| p.key == element.key) {
continue;
}
sensor_presets.push(Preset {
id: element.key.clone(),
key: element.key.clone(),
unit: element.unit.clone(),
viz: element.viz.kind().to_owned(),
label: element.label.clone(),
labels: element.labels.clone(),
scope: element.scope.clone(),
band: element.band,
stat: element.stat,
span: element.span,
value: element.value,
state: element.state.clone(),
});
}
}
Self {
sensor_presets,
theme,
messages,
}
}
pub fn is_empty(&self) -> bool {
self.sensor_presets.is_empty() && self.theme.is_none() && self.messages.is_empty()
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string(self)
}
}
fn is_false(value: &bool) -> bool {
!*value
}
#[cfg(test)]
mod tests {
use super::*;
use pamoja_profile::{ElementSpec, Presentation, Viz};
fn water_profile() -> Profile {
Profile::well_level().with_presentation(
Presentation::new()
.with_element(
ElementSpec::new("water_turbidity", "ntu", "Turbidity", Viz::Gauge)
.with_band(0.0, 5.0)
.on(Scope::Links(vec!["mesh".into()])),
)
.with_element(
ElementSpec::new("packets_dropped", "count", "Packets dropped", Viz::Count)
.as_stat(),
)
.with_message("event.filter_clog", "Filter clogged"),
)
}
#[test]
fn from_profiles_flattens_every_element_to_a_preset() {
let profile = water_profile();
let catalog = Catalog::from_profiles(&[&profile]);
assert_eq!(catalog.sensor_presets.len(), 2);
let turbidity = &catalog.sensor_presets[0];
assert_eq!(turbidity.viz, "radial");
assert_eq!(turbidity.band, Some([0.0, 5.0]));
assert!(!turbidity.stat);
}
#[test]
fn duplicate_keys_across_profiles_are_kept_once() {
let profile = water_profile();
let catalog = Catalog::from_profiles(&[&profile, &profile]);
assert_eq!(
catalog.sensor_presets.len(),
2,
"the second copy is skipped"
);
}
#[test]
fn a_profile_without_presentation_yields_an_empty_catalog() {
let plain = Profile::well_level();
assert!(Catalog::from_profiles(&[&plain]).is_empty());
}
#[test]
fn the_json_uses_the_page_catalog_shape() {
let profile = water_profile();
let json = Catalog::from_profiles(&[&profile])
.to_json()
.expect("serialize");
assert!(json.contains("\"sensorPresets\""));
assert!(json.contains("\"viz\":\"count\""));
assert!(json.contains("\"scope\":{\"links\":[\"mesh\"]}"));
assert!(json.contains("\"scope\":\"always\""));
assert!(json.contains("\"messages\":{\"event.filter_clog\":\"Filter clogged\"}"));
}
}