pamoja_profile/presentation.rs
1//! How a profile presents its custom elements on the local-first dashboard.
2//!
3//! The dashboard renders a language-neutral fleet snapshot and, by default, picks a
4//! graphic for each reading from its key and unit. That covers the common quantities,
5//! but a community often measures something we never anticipated - a water turbidity
6//! probe, a pH meter, a custom node stat. A [`Presentation`] lets a profile *declare*
7//! those elements as plain data: the graphic to draw them with, their safe band, a
8//! label, which groups they are offered on, and a small theme. It is part of the same
9//! shareable manifest a community already authors, so a new sensor type needs no code
10//! and no change to the dashboard.
11//!
12//! The declaration is presentation only. Values still travel in the snapshot as raw
13//! numbers and stable keys; this names how to *show* them.
14
15use std::collections::{BTreeMap, BTreeSet};
16
17use serde::{Deserialize, Serialize};
18
19/// The graphic a reading is drawn with on the dashboard.
20///
21/// The names are the instrument, not the quantity, so a profile chooses the shape that
22/// reads best for its data: a 270-degree arch [`Gauge`](Viz::Gauge) for a fraction, a
23/// [`Bar`](Viz::Bar) for a tank, a [`Switch`](Viz::Switch) for an on/off state. Each
24/// maps to one of the dashboard's hand-drawn visualizations through
25/// [`kind`](Viz::kind).
26#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "snake_case")]
28pub enum Viz {
29 /// A rolling sparkline of recent values. The default for an unfamiliar quantity.
30 Spark,
31 /// A 270-degree arch gauge, for a fraction or percentage.
32 Gauge,
33 /// A half-dial with a needle, for a pressure or flow reading.
34 Dial,
35 /// A horizontal bar with a safe-band tick, for a level or stock.
36 Bar,
37 /// A thermometer, for a temperature.
38 Thermometer,
39 /// A liquid-filled droplet, for humidity or moisture.
40 Droplet,
41 /// A segmented battery cell, for a state of charge or voltage.
42 Battery,
43 /// An anemometer, for wind speed.
44 Wind,
45 /// A sun whose corona grows with the reading, for illuminance.
46 Sun,
47 /// An acoustic waveform, for sound level or an acoustic event.
48 Wave,
49 /// A labeled state chip, lit when the state reads as "on". For a discrete state.
50 Switch,
51 /// A pipe valve, open along the flow or closed across it. For a controllable valve.
52 Valve,
53 /// A row of hash-chained blocks, for a tamper-evident record count.
54 Chain,
55 /// A neighbor-mesh topology map, for a mesh node's peers.
56 Mesh,
57 /// A plain numeric counter, for a node or network stat.
58 Count,
59}
60
61impl Viz {
62 /// Every graphic, in a stable order, so a caller can enumerate the available
63 /// visualizations (and a build can check each one still renders).
64 pub const ALL: [Viz; 15] = [
65 Viz::Spark,
66 Viz::Gauge,
67 Viz::Dial,
68 Viz::Bar,
69 Viz::Thermometer,
70 Viz::Droplet,
71 Viz::Battery,
72 Viz::Wind,
73 Viz::Sun,
74 Viz::Wave,
75 Viz::Switch,
76 Viz::Valve,
77 Viz::Chain,
78 Viz::Mesh,
79 Viz::Count,
80 ];
81
82 /// Returns the graphic as a manifest writes it.
83 ///
84 /// [`kind`](Viz::kind) is the token the dashboard renderer dispatches on, which for
85 /// three graphics is not the word a manifest uses. This is the word.
86 ///
87 /// # Returns
88 ///
89 /// The manifest name, such as `"thermometer"` or `"switch"`.
90 ///
91 /// # Examples
92 ///
93 /// ```
94 /// use pamoja_profile::Viz;
95 ///
96 /// assert_eq!(Viz::Thermometer.name(), "thermometer");
97 /// assert_eq!(Viz::Thermometer.kind(), "therm");
98 /// ```
99 pub fn name(self) -> &'static str {
100 match self {
101 Viz::Spark => "spark",
102 Viz::Gauge => "gauge",
103 Viz::Dial => "dial",
104 Viz::Bar => "bar",
105 Viz::Thermometer => "thermometer",
106 Viz::Droplet => "droplet",
107 Viz::Battery => "battery",
108 Viz::Wind => "wind",
109 Viz::Sun => "sun",
110 Viz::Wave => "wave",
111 Viz::Switch => "switch",
112 Viz::Valve => "valve",
113 Viz::Chain => "chain",
114 Viz::Mesh => "mesh",
115 Viz::Count => "count",
116 }
117 }
118
119 /// Returns the graphic a manifest name refers to.
120 ///
121 /// # Arguments
122 ///
123 /// * `name` - the manifest name, such as `"droplet"`.
124 ///
125 /// # Returns
126 ///
127 /// The graphic, or `None` for a name no graphic has.
128 ///
129 /// # Examples
130 ///
131 /// ```
132 /// use pamoja_profile::Viz;
133 ///
134 /// assert_eq!(Viz::from_name("droplet"), Some(Viz::Droplet));
135 /// assert_eq!(Viz::from_name("hologram"), None);
136 /// ```
137 pub fn from_name(name: &str) -> Option<Viz> {
138 Viz::ALL.into_iter().find(|viz| viz.name() == name)
139 }
140
141 /// Returns the dashboard visualization kind this graphic renders as.
142 ///
143 /// The dashboard's renderer dispatches on a small set of internal kind strings; a
144 /// few friendly names differ from them ([`Gauge`](Viz::Gauge) draws the `radial`
145 /// arch, [`Thermometer`](Viz::Thermometer) the `therm` instrument,
146 /// [`Switch`](Viz::Switch) the `chip`). This is the value carried on the wire so the
147 /// page needs no lookup of its own.
148 ///
149 /// # Returns
150 ///
151 /// The stable visualization kind, such as `"radial"` or `"bar"`.
152 pub fn kind(self) -> &'static str {
153 match self {
154 Viz::Spark => "spark",
155 Viz::Gauge => "radial",
156 Viz::Dial => "dial",
157 Viz::Bar => "bar",
158 Viz::Thermometer => "therm",
159 Viz::Droplet => "droplet",
160 Viz::Battery => "battery",
161 Viz::Wind => "wind",
162 Viz::Sun => "sun",
163 Viz::Wave => "wave",
164 Viz::Switch => "chip",
165 Viz::Valve => "valve",
166 Viz::Chain => "chain",
167 Viz::Mesh => "mesh",
168 Viz::Count => "count",
169 }
170 }
171}
172
173/// Which groups a declared element is offered on when a user adds a sensor.
174///
175/// A custom element rarely makes sense everywhere: a mesh-routing stat belongs only on
176/// a mesh node, while a quality-of-life detector a community wants on every node is
177/// [`Always`](Scope::Always). This gates the add-sensor dialog so a profile's element
178/// appears only where it applies.
179#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
180#[serde(rename_all = "snake_case")]
181pub enum Scope {
182 /// Offered on every group, whatever its link.
183 #[default]
184 Always,
185 /// Offered only on groups whose link kind is one of these, such as `["mesh"]`.
186 Links(Vec<String>),
187}
188
189/// A custom sensor or node stat a profile contributes to the dashboard.
190///
191/// This is the unit of a [`Presentation`]: one element keyed by a stable, language-
192/// neutral key, drawn with a chosen [`Viz`], scoped to the groups it belongs on, and
193/// labeled for people who do not read the key. The snapshot still carries the raw
194/// value under `key`; this names how to show it.
195#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
196#[serde(deny_unknown_fields)]
197pub struct ElementSpec {
198 /// The stable, language-neutral element key, such as `"water_turbidity"`.
199 pub key: String,
200 /// The canonical unit name, such as `"ntu"`, `"ph"`, or `"count"`.
201 pub unit: String,
202 /// A human-readable fallback label, shown when no localized label is available.
203 pub label: String,
204 /// Optional per-locale labels, keyed by locale tag (`"en"`, `"sw"`, ...). A locale
205 /// present here is shown in that locale; otherwise the page falls back to
206 /// [`label`](ElementSpec::label).
207 #[serde(default, skip_serializing_if = "Option::is_none")]
208 pub labels: Option<BTreeMap<String, String>>,
209 /// The graphic this element is drawn with.
210 pub viz: Viz,
211 /// The safe band `[low, high]` in the element's unit, drawn as the gauge's safe zone.
212 #[serde(default, skip_serializing_if = "Option::is_none")]
213 pub band: Option<[f32; 2]>,
214 /// Whether this is a node or network stat rather than a measurement of the world.
215 /// Stats are counted and rendered apart from sensors. Defaults `false`.
216 #[serde(default)]
217 pub stat: bool,
218 /// Which groups this element is offered on. Defaults to [`Scope::Always`].
219 #[serde(default)]
220 pub scope: Scope,
221 /// Whether the element's tile spans two columns, for a wide graphic. Defaults `false`.
222 #[serde(default)]
223 pub span: bool,
224 /// A starting numeric value for the add-sensor dialog, before a real sample arrives.
225 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub value: Option<f32>,
227 /// A starting discrete state code, such as `"state.closed"`, for a non-numeric element.
228 #[serde(default, skip_serializing_if = "Option::is_none")]
229 pub state: Option<String>,
230}
231
232impl ElementSpec {
233 /// Declares a numeric element drawn with the given graphic.
234 ///
235 /// # Arguments
236 ///
237 /// * `key` - the stable, language-neutral element key.
238 /// * `unit` - the canonical unit name.
239 /// * `label` - a human-readable fallback label.
240 /// * `viz` - the graphic to draw it with.
241 ///
242 /// # Returns
243 ///
244 /// A measurement element offered on every group, with no band yet.
245 pub fn new(
246 key: impl Into<String>,
247 unit: impl Into<String>,
248 label: impl Into<String>,
249 viz: Viz,
250 ) -> Self {
251 Self {
252 key: key.into(),
253 unit: unit.into(),
254 label: label.into(),
255 labels: None,
256 viz,
257 band: None,
258 stat: false,
259 scope: Scope::Always,
260 span: false,
261 value: None,
262 state: None,
263 }
264 }
265
266 /// Sets the safe band drawn as the graphic's safe zone.
267 ///
268 /// # Arguments
269 ///
270 /// * `low` - the bottom of the safe band.
271 /// * `high` - the top of the safe band.
272 ///
273 /// # Returns
274 ///
275 /// The element, for chaining.
276 pub fn with_band(mut self, low: f32, high: f32) -> Self {
277 self.band = Some([low, high]);
278 self
279 }
280
281 /// Restricts the groups this element is offered on.
282 ///
283 /// # Arguments
284 ///
285 /// * `scope` - the groups the add-sensor dialog offers this element on.
286 ///
287 /// # Returns
288 ///
289 /// The element, for chaining.
290 pub fn on(mut self, scope: Scope) -> Self {
291 self.scope = scope;
292 self
293 }
294
295 /// Marks the element as a node or network stat rather than a measurement.
296 ///
297 /// # Returns
298 ///
299 /// The element, for chaining.
300 pub fn as_stat(mut self) -> Self {
301 self.stat = true;
302 self
303 }
304
305 /// Sets a starting value shown until the first real sample arrives.
306 ///
307 /// # Arguments
308 ///
309 /// * `value` - the starting numeric value.
310 ///
311 /// # Returns
312 ///
313 /// The element, for chaining.
314 pub fn with_value(mut self, value: f32) -> Self {
315 self.value = Some(value);
316 self
317 }
318
319 /// Sets a starting discrete state code for a non-numeric element.
320 ///
321 /// # Arguments
322 ///
323 /// * `state` - the starting state code, such as `"state.closed"`.
324 ///
325 /// # Returns
326 ///
327 /// The element, for chaining.
328 pub fn with_state(mut self, state: impl Into<String>) -> Self {
329 self.state = Some(state.into());
330 self
331 }
332
333 /// Spans the element's tile across two columns, for a wide graphic.
334 ///
335 /// # Returns
336 ///
337 /// The element, for chaining.
338 pub fn wide(mut self) -> Self {
339 self.span = true;
340 self
341 }
342
343 /// Adds a localized label for one locale.
344 ///
345 /// # Arguments
346 ///
347 /// * `locale` - the locale tag, such as `"sw"`.
348 /// * `label` - the element's label in that locale.
349 ///
350 /// # Returns
351 ///
352 /// The element, for chaining.
353 pub fn with_locale_label(
354 mut self,
355 locale: impl Into<String>,
356 label: impl Into<String>,
357 ) -> Self {
358 self.labels
359 .get_or_insert_with(BTreeMap::new)
360 .insert(locale.into(), label.into());
361 self
362 }
363}
364
365/// A small set of theme tokens a profile can set on the dashboard.
366///
367/// Each token, when present, tints one of the page's CSS custom properties, so a
368/// deployment can carry its own brand accent and status palette. Modest by design: it
369/// tints the existing console rather than restyling it. Colors are any CSS color.
370#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
371#[serde(rename_all = "camelCase", deny_unknown_fields)]
372pub struct Theme {
373 /// The brand/interaction accent (links, focus glow, brand mark), such as `"#3fb1c8"`.
374 #[serde(default, skip_serializing_if = "Option::is_none")]
375 pub accent: Option<String>,
376 /// The healthy/ok status color, which also tints an in-band gauge.
377 #[serde(default, skip_serializing_if = "Option::is_none")]
378 pub ok: Option<String>,
379 /// The warning status color.
380 #[serde(default, skip_serializing_if = "Option::is_none")]
381 pub warn: Option<String>,
382 /// The alarm status color.
383 #[serde(default, skip_serializing_if = "Option::is_none")]
384 pub alarm: Option<String>,
385 /// The unfilled track/rail color behind gauges and progress bars.
386 #[serde(default, skip_serializing_if = "Option::is_none")]
387 pub track: Option<String>,
388}
389
390/// A piece of human-facing text a profile supplies, either one string for every locale or
391/// a per-locale map.
392///
393/// Used for the messages a profile localizes for the dashboard - its custom discrete state
394/// codes and event codes. In a manifest it is a bare string for the simple case or an
395/// object keyed by locale tag:
396///
397/// ```json
398/// { "state.flushing": "Flushing", "event.filter_clog": { "en": "Filter clogged", "sw": "Kichujio kimeziba" } }
399/// ```
400#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
401#[serde(untagged)]
402pub enum LocalizedText {
403 /// One text shown in every locale that has no specific translation.
404 Plain(String),
405 /// Per-locale text, keyed by locale tag (`"en"`, `"sw"`, ...).
406 PerLocale(BTreeMap<String, String>),
407}
408
409impl LocalizedText {
410 /// Text written out in several locales at once.
411 ///
412 /// # Arguments
413 ///
414 /// * `texts` - `(locale tag, text)` pairs, such as `[("en", "Open"), ("sw", "Wazi")]`.
415 /// A locale that is missing falls back to `"en"`.
416 ///
417 /// # Returns
418 ///
419 /// The per-locale text.
420 ///
421 /// # Examples
422 ///
423 /// ```
424 /// use pamoja_profile::{LocalizedText, Presentation};
425 ///
426 /// let presentation = Presentation::new().with_message(
427 /// "state.lamp_on",
428 /// LocalizedText::per_locale([("en", "On"), ("fr", "Allumée"), ("sw", "Imewashwa")]),
429 /// );
430 /// assert_eq!(presentation.messages.len(), 1);
431 /// ```
432 pub fn per_locale<K, V>(texts: impl IntoIterator<Item = (K, V)>) -> Self
433 where
434 K: Into<String>,
435 V: Into<String>,
436 {
437 LocalizedText::PerLocale(
438 texts
439 .into_iter()
440 .map(|(locale, text)| (locale.into(), text.into()))
441 .collect(),
442 )
443 }
444}
445
446impl<K: Into<String>, V: Into<String>, const N: usize> From<[(K, V); N]> for LocalizedText {
447 fn from(texts: [(K, V); N]) -> Self {
448 LocalizedText::per_locale(texts)
449 }
450}
451
452impl From<String> for LocalizedText {
453 fn from(text: String) -> Self {
454 LocalizedText::Plain(text)
455 }
456}
457
458impl From<&str> for LocalizedText {
459 fn from(text: &str) -> Self {
460 LocalizedText::Plain(text.to_owned())
461 }
462}
463
464impl From<BTreeMap<String, String>> for LocalizedText {
465 fn from(map: BTreeMap<String, String>) -> Self {
466 LocalizedText::PerLocale(map)
467 }
468}
469
470/// How a profile presents itself on the dashboard: its custom elements and theme.
471///
472/// A [`Profile`](crate::Profile) carries an optional `presentation`, so a deployment's
473/// dashboard offers exactly the sensor types its profiles introduce and renders them
474/// the way the profile intends. The dashboard turns these declarations into the catalog
475/// it serves to the page.
476#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
477#[serde(deny_unknown_fields)]
478pub struct Presentation {
479 /// The custom sensors and node stats this profile contributes.
480 #[serde(default)]
481 pub elements: Vec<ElementSpec>,
482 /// An optional theme that tints the dashboard.
483 #[serde(default, skip_serializing_if = "Option::is_none")]
484 pub theme: Option<Theme>,
485 /// Localized text for the stable codes this profile introduces, keyed by the page's
486 /// message key (a discrete state such as `"state.flushing"` or an event such as
487 /// `"event.filter_clog"`). The dashboard ships no translation for a code it never knew,
488 /// so a profile that emits a custom state or event supplies its wording here.
489 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
490 pub messages: BTreeMap<String, LocalizedText>,
491}
492
493impl Presentation {
494 /// Starts an empty presentation.
495 ///
496 /// # Returns
497 ///
498 /// A presentation with no elements and no theme.
499 pub fn new() -> Self {
500 Self::default()
501 }
502
503 /// Loads a presentation from its JSON text, the `presentation` object of a manifest.
504 ///
505 /// # Arguments
506 ///
507 /// * `text` - the JSON object.
508 ///
509 /// # Returns
510 ///
511 /// The presentation.
512 ///
513 /// # Errors
514 ///
515 /// Returns [`Error::Codec`](pamoja_core::Error::Codec) if the text is not a
516 /// presentation.
517 #[cfg(feature = "json")]
518 pub fn from_json(text: &str) -> pamoja_core::Result<Self> {
519 serde_json::from_str(text)
520 .map_err(|error| pamoja_core::Error::Codec(crate::format::explain(&error)))
521 }
522
523 /// Serializes this presentation to the JSON object a manifest carries.
524 ///
525 /// # Returns
526 ///
527 /// The pretty-printed JSON text.
528 ///
529 /// # Errors
530 ///
531 /// Returns [`Error::Codec`](pamoja_core::Error::Codec) if the presentation cannot be
532 /// serialized.
533 #[cfg(feature = "json")]
534 pub fn to_json(&self) -> pamoja_core::Result<String> {
535 serde_json::to_string_pretty(self)
536 .map_err(|error| pamoja_core::Error::Codec(error.to_string()))
537 }
538
539 /// Adds a custom element.
540 ///
541 /// # Arguments
542 ///
543 /// * `element` - the sensor or stat to contribute.
544 ///
545 /// # Returns
546 ///
547 /// The presentation, for chaining.
548 pub fn with_element(mut self, element: ElementSpec) -> Self {
549 self.elements.push(element);
550 self
551 }
552
553 /// Declares several custom elements at once.
554 ///
555 /// # Arguments
556 ///
557 /// * `elements` - the elements to declare, in the order they should be offered.
558 ///
559 /// # Returns
560 ///
561 /// The presentation, for chaining.
562 ///
563 /// # Examples
564 ///
565 /// ```
566 /// use pamoja_profile::{ElementSpec, Presentation, Viz};
567 ///
568 /// let presentation = Presentation::new().with_elements([
569 /// ElementSpec::new("ph", "ph", "Acidity", Viz::Dial).with_band(6.0, 8.0),
570 /// ElementSpec::new("water_turbidity", "ntu", "Turbidity", Viz::Gauge),
571 /// ]);
572 /// assert_eq!(presentation.elements.len(), 2);
573 /// ```
574 pub fn with_elements(mut self, elements: impl IntoIterator<Item = ElementSpec>) -> Self {
575 self.elements.extend(elements);
576 self
577 }
578
579 /// Sets the theme that tints the dashboard.
580 ///
581 /// # Arguments
582 ///
583 /// * `theme` - the theme tokens to apply.
584 ///
585 /// # Returns
586 ///
587 /// The presentation, for chaining.
588 pub fn with_theme(mut self, theme: Theme) -> Self {
589 self.theme = Some(theme);
590 self
591 }
592
593 /// Supplies localized text for a stable code this profile introduces.
594 ///
595 /// Use this for a custom discrete state or event the dashboard ships no wording for, so
596 /// the page renders it as words rather than the raw code.
597 ///
598 /// # Arguments
599 ///
600 /// * `key` - the page message key, such as `"state.flushing"` or `"event.filter_clog"`.
601 /// * `text` - the text, one string for every locale or a per-locale map.
602 ///
603 /// # Returns
604 ///
605 /// The presentation, for chaining.
606 pub fn with_message(mut self, key: impl Into<String>, text: impl Into<LocalizedText>) -> Self {
607 self.messages.insert(key.into(), text.into());
608 self
609 }
610
611 /// Checks the presentation for what the dashboard could not draw.
612 ///
613 /// [`Profile::check`](crate::Profile::check) runs this on the presentation a profile
614 /// carries.
615 ///
616 /// # Returns
617 ///
618 /// Nothing when every element, message, and theme color is usable.
619 ///
620 /// # Errors
621 ///
622 /// Returns [`Error::Codec`](pamoja_core::Error::Codec) naming the first problem: an
623 /// element with no key, unit, or label, a key declared twice, a band whose low end is
624 /// not below its high end, a starting value that is not a finite number, both a
625 /// starting value and a state, or a state that is not a `state.` code; a message whose
626 /// code is neither a `state.` nor an `event.` code, that is empty, or that gives no
627 /// `en` text; or a theme color that is empty.
628 ///
629 /// # Examples
630 ///
631 /// ```
632 /// use pamoja_profile::{ElementSpec, Presentation, Viz};
633 ///
634 /// let upside_down = Presentation::new().with_element(
635 /// ElementSpec::new("water_turbidity", "ntu", "Turbidity", Viz::Gauge).with_band(5.0, 0.0),
636 /// );
637 /// let refused = upside_down.check().unwrap_err().to_string();
638 /// assert!(refused.contains("the low end comes first"));
639 /// ```
640 pub fn check(&self) -> pamoja_core::Result<()> {
641 let mut keys = BTreeSet::new();
642 for element in &self.elements {
643 let key = &element.key;
644 if key.trim().is_empty() {
645 return refuse("an element needs a key".to_owned());
646 }
647 if element.unit.trim().is_empty() {
648 return refuse(format!("the element `{key}` has no unit"));
649 }
650 if element.label.trim().is_empty() {
651 return refuse(format!("the element `{key}` has no label"));
652 }
653 if !keys.insert(key.as_str()) {
654 return refuse(format!("the element `{key}` is declared twice"));
655 }
656 if let Some([low, high]) = element.band {
657 if !(low.is_finite() && high.is_finite() && low < high) {
658 return refuse(format!(
659 "the element `{key}` has a band of {low} to {high}; the low end comes first"
660 ));
661 }
662 }
663 if element.value.is_some_and(|value| !value.is_finite()) {
664 return refuse(format!(
665 "the element `{key}` has a starting value that is not a finite number"
666 ));
667 }
668 if let Some(state) = &element.state {
669 if element.value.is_some() {
670 return refuse(format!(
671 "the element `{key}` starts with both a value and a state; a reading is one or the other"
672 ));
673 }
674 if !state.starts_with("state.") {
675 return refuse(format!(
676 "the element `{key}` starts in `{state}`, which is not a `state.` code"
677 ));
678 }
679 }
680 }
681 for (code, text) in &self.messages {
682 if !(code.starts_with("state.") || code.starts_with("event.")) {
683 return refuse(format!(
684 "the message `{code}` is neither a `state.` nor an `event.` code"
685 ));
686 }
687 match text {
688 LocalizedText::Plain(text) if text.trim().is_empty() => {
689 return refuse(format!("the message `{code}` is empty"));
690 }
691 LocalizedText::PerLocale(map) if !map.contains_key("en") => {
692 return refuse(format!(
693 "the message `{code}` gives no `en` text, which every other locale falls back to"
694 ));
695 }
696 _ => {}
697 }
698 }
699 if let Some(theme) = &self.theme {
700 for (name, color) in [
701 ("accent", &theme.accent),
702 ("ok", &theme.ok),
703 ("warn", &theme.warn),
704 ("alarm", &theme.alarm),
705 ("track", &theme.track),
706 ] {
707 if color
708 .as_deref()
709 .is_some_and(|color| color.trim().is_empty())
710 {
711 return refuse(format!("the theme's `{name}` color is empty"));
712 }
713 }
714 }
715 Ok(())
716 }
717}
718
719/// Refuses a profile or presentation, naming what is wrong with it.
720pub(crate) fn refuse(reason: String) -> pamoja_core::Result<()> {
721 Err(pamoja_core::Error::Codec(reason))
722}
723
724#[cfg(test)]
725mod tests {
726 use super::*;
727
728 #[test]
729 fn a_graphic_name_is_the_word_the_manifest_carries() {
730 for viz in Viz::ALL {
731 let written = serde_json::to_value(viz).expect("a graphic serializes");
732 assert_eq!(written.as_str(), Some(viz.name()), "{viz:?}");
733 assert_eq!(Viz::from_name(viz.name()), Some(viz));
734 }
735 }
736
737 #[test]
738 fn every_viz_maps_to_its_documented_render_kind() {
739 // The full set of graphics and the render kind each draws as. ALL and this table must
740 // agree, and every kind is distinct, so a new graphic cannot silently collide or be
741 // left out of the enumerated set.
742 let table = [
743 (Viz::Spark, "spark"),
744 (Viz::Gauge, "radial"),
745 (Viz::Dial, "dial"),
746 (Viz::Bar, "bar"),
747 (Viz::Thermometer, "therm"),
748 (Viz::Droplet, "droplet"),
749 (Viz::Battery, "battery"),
750 (Viz::Wind, "wind"),
751 (Viz::Sun, "sun"),
752 (Viz::Wave, "wave"),
753 (Viz::Switch, "chip"),
754 (Viz::Valve, "valve"),
755 (Viz::Chain, "chain"),
756 (Viz::Mesh, "mesh"),
757 (Viz::Count, "count"),
758 ];
759 assert_eq!(
760 table.len(),
761 Viz::ALL.len(),
762 "the table covers every variant in ALL"
763 );
764 let mut kinds = std::collections::HashSet::new();
765 for (viz, kind) in table {
766 assert_eq!(viz.kind(), kind);
767 assert!(Viz::ALL.contains(&viz), "{kind} is in ALL");
768 assert!(kinds.insert(kind), "{kind} is unique");
769 }
770 }
771
772 #[test]
773 fn an_element_builds_with_band_and_scope() {
774 let element = ElementSpec::new("water_turbidity", "ntu", "Turbidity", Viz::Gauge)
775 .with_band(0.0, 5.0)
776 .on(Scope::Links(vec!["mesh".into()]));
777 assert_eq!(element.band, Some([0.0, 5.0]));
778 assert!(matches!(element.scope, Scope::Links(_)));
779 assert!(!element.stat);
780 }
781
782 #[cfg(feature = "json")]
783 #[test]
784 fn viz_serializes_to_its_friendly_name() {
785 assert_eq!(serde_json::to_string(&Viz::Gauge).unwrap(), "\"gauge\"");
786 assert_eq!(serde_json::to_string(&Viz::Switch).unwrap(), "\"switch\"");
787 }
788
789 #[cfg(feature = "json")]
790 #[test]
791 fn scope_round_trips_in_both_forms() {
792 assert_eq!(serde_json::to_string(&Scope::Always).unwrap(), "\"always\"");
793 let links = Scope::Links(vec!["mesh".into()]);
794 let json = serde_json::to_string(&links).unwrap();
795 assert_eq!(json, r#"{"links":["mesh"]}"#);
796 assert_eq!(serde_json::from_str::<Scope>(&json).unwrap(), links);
797 }
798
799 #[cfg(feature = "json")]
800 #[test]
801 fn a_presentation_round_trips_through_json() {
802 let presentation = Presentation::new()
803 .with_element(
804 ElementSpec::new("water_turbidity", "ntu", "Turbidity", Viz::Gauge)
805 .with_band(0.0, 5.0)
806 .with_locale_label("sw", "Utiririko"),
807 )
808 .with_element(
809 ElementSpec::new("packets_dropped", "count", "Packets dropped", Viz::Count)
810 .as_stat()
811 .on(Scope::Links(vec!["mesh".into()])),
812 )
813 .with_theme(Theme {
814 accent: Some("#3fb1c8".into()),
815 ..Theme::default()
816 })
817 .with_message("state.flushing", "Flushing")
818 .with_message(
819 "event.filter_clog",
820 BTreeMap::from([
821 ("en".to_owned(), "Filter clogged".to_owned()),
822 ("sw".to_owned(), "Kichujio kimeziba".to_owned()),
823 ]),
824 );
825 let json = serde_json::to_string(&presentation).unwrap();
826 let restored: Presentation = serde_json::from_str(&json).unwrap();
827 assert_eq!(presentation, restored);
828 }
829
830 #[cfg(feature = "json")]
831 #[test]
832 fn a_message_is_a_bare_string_or_a_locale_map_on_the_wire() {
833 let presentation = Presentation::new()
834 .with_message("state.flushing", "Flushing")
835 .with_message(
836 "event.filter_clog",
837 BTreeMap::from([("en".to_owned(), "Filter clogged".to_owned())]),
838 );
839 let json = serde_json::to_string(&presentation.messages).unwrap();
840 assert_eq!(
841 json,
842 r#"{"event.filter_clog":{"en":"Filter clogged"},"state.flushing":"Flushing"}"#
843 );
844 }
845}