Skip to main content

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}