Skip to main content

navcore_signalk/
units.rs

1//! A mariner's own display units, as `signalk-server`'s `unitpreferences`
2//! feature states them.
3//!
4//! Signal K itself is SI on the wire and says nothing about how a value
5//! should be shown -- that is what `unitpreferences` is for, a
6//! `signalk-server` feature (not part of the core specification, the same
7//! already-observed-convention standing this crate already gives
8//! [`crate::AisTargetStatus`] and [`crate::NotificationSeverity`]) that
9//! lets a mariner pick nautical miles, kilometres or statute miles for
10//! distance, once, on the server, for every client to honour alike.
11//!
12//! # Why this does not read the server's own conversion formula
13//!
14//! `GET /signalk/v1/unitpreferences/active` answers with a `formula` and
15//! `inverseFormula` per category -- a short expression, `"value * 1.94384"`,
16//! meant to be evaluated. This crate deliberately never evaluates it: doing
17//! so needs a small expression interpreter, which is new conversion math of
18//! exactly the kind this workspace exists to avoid growing twice. Instead,
19//! `conversions` below is a table of `(scale, offset)` pairs -- one row
20//! per `target = value * scale + offset` -- computed once, by hand, from
21//! every `formula` string `GET /signalk/v1/unitpreferences/definitions`
22//! answered on a real `signalk-server` (confirmed live; see
23//! `crates/signalk/testdata/unitpreferences.json`, the fixture this table
24//! is checked against, and this module's own tests). What is read from a
25//! server at runtime is only *which* row a mariner's preference names --
26//! never a formula to evaluate.
27//!
28//! # What is not covered
29//!
30//! Every target unit `signalk-server` ships is in `conversions`, with two
31//! exceptions, both left unread the same way an unrecognised path is
32//! anywhere else in this crate -- skipped, not refused:
33//!
34//! - **Beaufort** (`m/s` -> `Bf`), the one target in the entire catalogue
35//!   whose own formula is not affine -- `(value / 0.836)^(2/3)`, a power
36//!   law. Modelling it would mean either a second kind of table entry for
37//!   one row, or the interpreter this module exists to avoid; a mariner
38//!   who picks Beaufort for wind speed gets the same "not a reading this
39//!   crate understands" [`Preferences::convert`] already gives any other
40//!   unrecognised target.
41//! - **Duration formatters** (`s` -> `HH:MM:SS` and six siblings), which
42//!   are not a numeric scale at all -- `formatDurationHMS(value)` turns a
43//!   seconds count into a clock-formatted *string*, which is a formatting
44//!   concern, not a unit conversion, and not this module's to grow into.
45//!   The plain decimal ones (`s` -> `hour`, `minute`, `day`, ...) are
46//!   ordinary affine conversions and are covered.
47//!
48//! Three base units are themselves formats rather than physical
49//! quantities -- `RFC 3339 (UTC)` and `Epoch Seconds` (the `dateTime`/
50//! `epoch` categories' own timestamp representations) and `bool` (the
51//! `boolean` category) -- each with exactly one target, itself. The
52//! `target-equals-base` rule [`Preferences::convert`] applies before ever
53//! consulting `conversions` already answers that trivially and
54//! correctly (the value passes through unchanged), without this module
55//! needing to know or care that a category is not really a measurement.
56//!
57//! # The `target-equals-base` rule
58//!
59//! A preference whose `targetUnit` is exactly the category's own Signal K
60//! base unit (`"m"` for `length`, `"K"` for `temperature`, ...) means "no
61//! conversion" -- confirmed live: `signalk-server` sends that literal
62//! string, not the differently-spelled identity row its own
63//! `conversions` table happens to carry for some base units (`m`'s own
64//! identity entry is named `"meter"`, never `"m"`; several base units --
65//! `V`, `A`, `bool`, `tr`, the two timestamp formats -- carry no entry
66//! *but* their own name at all). [`Preferences::convert`] checks this
67//! before ever looking a target up in `conversions`, so every category
68//! has a correct identity regardless of which, if either, spelling the
69//! table itself happens to carry.
70
71use std::collections::BTreeMap;
72
73use serde_json::Value;
74
75/// The Signal K base unit `category` (as `GET /signalk/v1/unitpreferences/categories`'s
76/// own `categoryToBaseUnit` names it, confirmed live) reads its preference
77/// out of, or `None` for a category this crate has never heard of.
78fn category_base_unit(category: &str) -> Option<&'static str> {
79    Some(match category {
80        "speed" => "m/s",
81        "temperature" => "K",
82        "pressure" => "Pa",
83        "distance" => "m",
84        "depth" => "m",
85        "angle" => "rad",
86        "angleDegrees" => "deg",
87        "angularVelocity" => "rad/s",
88        "volume" => "m3",
89        "voltage" => "V",
90        "current" => "A",
91        "power" => "W",
92        "percentage" => "ratio",
93        "frequency" => "Hz",
94        "time" => "s",
95        "charge" => "C",
96        "volumeRate" => "m3/s",
97        "length" => "m",
98        "energy" => "J",
99        "mass" => "kg",
100        "area" => "m2",
101        "dateTime" => "RFC 3339 (UTC)",
102        "epoch" => "Epoch Seconds",
103        "unitless" => "tr",
104        "boolean" => "bool",
105        "dataSize" => "B",
106        _ => return None,
107    })
108}
109
110/// Every base unit this crate converts out of, and the target units
111/// reachable from it as `(scale, offset)` pairs -- see this module's own
112/// top doc for where these numbers come from and what is left out.
113/// `crates/signalk/testdata/unitpreferences.json` is the fixture this
114/// table is checked against.
115fn conversions(base_unit: &str) -> &'static [(&'static str, f64, f64)] {
116    match base_unit {
117        "m" => &[
118            ("mm", 1000.0, 0.0),
119            ("cm", 100.0, 0.0),
120            ("fathom", 0.5467468562055768, 0.0),
121            ("angstrom", 10000000000.0, 0.0),
122            ("AU", 6.684585813036146e-12, 0.0),
123            ("datamile", 0.0005468066491688539, 0.0),
124            ("foot", 3.280839895013124, 0.0),
125            ("furlong", 0.004970178926441352, 0.0),
126            ("inch", 39.37007874015748, 0.0),
127            ("league", 0.0002071251035625518, 0.0),
128            ("light-minute", 5.5594008077809377e-11, 0.0),
129            ("light-second", 3.3356404846685622e-09, 0.0),
130            ("light-year", 1.0570234557732929e-16, 0.0),
131            ("meter", 1.0, 0.0),
132            ("kilometer", 0.001, 0.0), // km
133            ("mile", 0.000621371192237334, 0.0),
134            ("naut-mile", 0.0005399568034557236, 0.0), // nmi
135            ("parsec", 3.2407788498994385e-17, 0.0),
136            ("pica", 236.22047262694525, 0.0),
137            ("point", 2834.645667505735, 0.0),
138            ("redshift", 7.67593433391696e-27, 0.0),
139            ("rod", 0.1988466892026248, 0.0),
140            ("yard", 1.0936132983377078, 0.0),
141        ],
142        "m/s" => &[
143            ("kn", 1.94384, 0.0),
144            ("km/h", 3.6, 0.0),
145            ("mph", 2.2369362920544025, 0.0),
146            ("fps", 3.280839895013124, 0.0),
147        ],
148        "K" => &[
149            ("C", 1.0, -273.15),  // °C
150            ("F", 1.8, -459.67), // °F
151        ],
152        "Pa" => &[
153            ("hPa", 0.01, 0.0),
154            ("mbar", 0.01, 0.0),
155            ("bar", 1e-05, 0.0),
156            ("psi", 0.0001450376807894691, 0.0),
157            ("inHg", 0.00029529987601298443, 0.0),
158            ("mmHg", 0.0075006168507298, 0.0),
159            ("atm", 9.869232667160129e-06, 0.0),
160            ("cmh2o", 0.0101974428892211, 0.0),
161            ("inh2o", 0.004014741294968937, 0.0),
162            ("torr", 0.0075006168507298, 0.0),
163        ],
164        "m3" => &[
165            ("beerbarrel", 8.521679072308338, 0.0),
166            ("beerbarrel-imp", 6.110256897196883, 0.0),
167            ("bushel", 28.37759178221265, 0.0),
168            ("cup", 4226.752810932216, 0.0),
169            ("fluid-ounce", 33814.02254462713, 0.0),
170            ("fluid-ounce-imp", 35195.07972785405, 0.0),
171            ("gallon", 264.1720512415585, 0.0),
172            ("gallon-imp", 219.9692482990878, 0.0),
173            ("liter", 1000.0, 0.0),
174            ("oilbarrel", 6.289810770432104, 0.0),
175            ("pint-imp", 1759.7539863927022, 0.0),
176            ("quart", 1056.688204966234, 0.0),
177            ("tablespoon", 67628.04531793189, 0.0),
178            ("teaspoon", 202884.1355421759, 0.0),
179        ],
180        "m3/s" => &[
181            ("L/h", 3600000.0, 0.0),
182            ("L/min", 60000.0, 0.0),
183            ("gal/h", 264.17205236, 0.0),
184            ("gal-imp/h", 219.9692483, 0.0),
185        ],
186        "kg" => &[
187            ("AMU", 6.0221412901167415e+26, 0.0),
188            ("carat", 5000.0, 0.0),
189            ("dalton", 6.0221412901167415e+26, 0.0),
190            ("dram", 564.3833897001838, 0.0),
191            ("grain", 15432.358352941434, 0.0),
192            ("gram", 1000.0, 0.0),
193            ("kilogram", 1.0, 0.0),
194            ("metric-ton", 0.001, 0.0),
195            ("ounce", 35.27396198068672, 0.0),
196            ("pound", 2.2046226218487757, 0.0),
197            ("short-ton", 0.001102311310924388, 0.0),
198            ("slug", 0.0685217660314843, 0.0),
199            ("stone", 0.15747304441776971, 0.0),
200        ],
201        "J" => &[
202            ("btu", 0.0009478169879134378, 0.0),
203            ("calorie", 0.2390057361376673, 0.0),
204            ("Calorie", 0.00023900573613766727, 0.0),
205            ("electronvolt", 6.241509074460763e+18, 0.0),
206            ("erg", 10000000.0, 0.0),
207            ("therm-US", 9.480434279733487e-09, 0.0),
208            ("Wh", 0.0002777777777777778, 0.0),
209        ],
210        "W" => &[
211            ("kW", 0.001, 0.0),
212            ("horsepower", 0.0013410220888438076, 0.0),
213            ("watt", 1.0, 0.0),
214        ],
215        "rad" => &[
216            ("arcminute", 3437.746770784939, 0.0),
217            ("arcsecond", 206264.8062470964, 0.0),
218            ("degree", 57.29577951308231, 0.0), // °
219            ("gradian", 63.66197723675812, 0.0),
220            ("radian", 1.0, 0.0),
221            ("rotation", 0.1591549430918954, 0.0),
222        ],
223        "deg" => &[
224            ("arcminute", 60.00000000000001, 0.0),
225            ("arcsecond", 3600.000000000001, 0.0),
226            ("degree", 1.0, 0.0), // °
227            ("gradian", 1.111111111111111, 0.0),
228            ("radian", 0.0174532925199433, 0.0),
229            ("rotation", 0.0027777777777777783, 0.0),
230        ],
231        "rad/s" => &[
232            ("deg/s", 57.2958, 0.0), // °/s
233            ("rpm", 9.549296585513723, 0.0),
234        ],
235        "Hz" => &[
236            ("rpm", 60.0, 0.0),
237            ("hertz", 1.0, 0.0),
238        ],
239        "C" => &[
240            ("Ah", 0.0002777777777777778, 0.0),
241            ("mAh", 0.277778, 0.0),
242        ],
243        "ratio" => &[
244            ("percent", 100.0, 0.0), // %
245        ],
246        "s" => &[
247            ("MM.xx", 0.016666666666666666, 0.0),   // min
248            ("HH.xx", 0.0002777777777777778, 0.0),  // hr
249            ("DD.xx", 1.1574074074074073e-05, 0.0), // days
250            ("century", 3.168876461541279e-10, 0.0),
251            ("day", 1.1574074074074073e-05, 0.0),
252            ("decade", 3.1688764615412793e-09, 0.0),
253            ("fortnight", 8.26719576719577e-07, 0.0),
254            ("hour", 0.0002777777777777778, 0.0),
255            ("minute", 0.016666666666666666, 0.0),
256            ("second", 1.0, 0.0),
257            ("week", 1.653439153439154e-06, 0.0),
258            ("year", 3.168876461541279e-08, 0.0),
259        ],
260        "m2" => &[
261            ("acre", 0.0002471053816137119, 0.0),
262            ("hectare", 0.0001, 0.0),
263            ("sqft", 10.7639, 0.0),
264        ],
265        "B" => &[
266            ("KB", 0.001, 0.0),
267            ("MB", 1e-06, 0.0),
268            ("GB", 1e-09, 0.0),
269            ("TB", 1e-12, 0.0),
270            ("KiB", 0.0009765625, 0.0),
271            ("MiB", 9.5367431640625e-07, 0.0),
272            ("GiB", 9.313225746154785e-10, 0.0),
273            ("TiB", 9.094947017729282e-13, 0.0),
274        ],
275        // V, A: signalk-server lists only their own identity conversion,
276        // which the target-equals-base check in `resolve` already answers.
277        _ => &[],
278    }
279}
280
281/// One category's own resolved preference: the fixed factor from its
282/// Signal K base unit into whatever a mariner asked for, and the label to
283/// show beside the converted figure.
284#[derive(Debug, Clone, PartialEq)]
285struct Resolved {
286    scale: f64,
287    offset: f64,
288    /// The label to show, when the server stated one.
289    ///
290    /// `None`, not empty, when the server states none -- confirmed live:
291    /// its own `/active` endpoint only merges one in for a `targetUnit`
292    /// its unit definitions have a `conversions` entry for, and omits it
293    /// outright for the target-equals-base identity (`length`'s own `m`,
294    /// on every built-in preset but the two imperial ones). A caller
295    /// distinguishes this from a category Signal K never mentioned at all
296    /// -- the unit to convert into is still read, only the word for it is
297    /// not -- and falls back to its own default label either way.
298    symbol: Option<String>,
299}
300
301/// What a server's `unitpreferences` states, as far as this crate reads
302/// it -- every category `category_base_unit` names a Signal K base unit
303/// for, present once the server has stated a target unit this crate
304/// converts (see this module's own top doc for the two kinds of target
305/// that leaves out), absent otherwise. [`Default`] is every category
306/// absent, which is exactly the state a caller should fall back to its
307/// own default unit for -- the same value [`Self::parse`] returns for an
308/// unreadable answer.
309#[derive(Debug, Clone, Default, PartialEq)]
310pub struct Preferences {
311    resolved: BTreeMap<String, Resolved>,
312}
313
314impl Preferences {
315    /// Reads `GET /signalk/v1/unitpreferences/active`'s own JSON body.
316    ///
317    /// Never fails: an answer this crate cannot make sense of -- the
318    /// wrong shape, every category naming a target unit it does not know,
319    /// or a category it has never heard of -- reads the same as no
320    /// preference stated at all, since a caller reacts to both by falling
321    /// back to its own default unit regardless of which it was.
322    #[must_use]
323    pub fn parse(value: &Value) -> Self {
324        let empty = Value::Object(serde_json::Map::new());
325        let categories = value.get("categories").unwrap_or(&empty);
326
327        let mut resolved = BTreeMap::new();
328        let Some(categories) = categories.as_object() else {
329            return Self { resolved };
330        };
331        for (category, entry) in categories {
332            let Some(base_unit) = category_base_unit(category) else {
333                continue;
334            };
335            let Some(target) = entry.get("targetUnit").and_then(Value::as_str) else {
336                continue;
337            };
338            let Some((scale, offset)) = resolve(base_unit, target) else {
339                continue;
340            };
341            // Absent and empty read the same -- see `Resolved::symbol`'s
342            // own doc.
343            let symbol = entry
344                .get("symbol")
345                .and_then(Value::as_str)
346                .filter(|symbol| !symbol.is_empty())
347                .map(str::to_owned);
348            resolved.insert(category.clone(), Resolved { scale, offset, symbol });
349        }
350        Self { resolved }
351    }
352
353    /// `si_value`, in `category`'s own Signal K base unit (its own SI
354    /// unit for everything but [`Self::distance_nm`]'s and its three
355    /// siblings' own nautical-mile/knot/metre convention -- see
356    /// `category_base_unit` for which is which), converted into
357    /// whatever a mariner's preference asks for, with the label to show
358    /// beside it. `None` when `category` names nothing
359    /// `category_base_unit` recognises, or nothing was stated (or
360    /// understood) for it -- the same two cases [`Self::parse`]'s own doc
361    /// gives.
362    ///
363    /// This is the one path every category not among nav-core's own four
364    /// canonical units goes through -- a mariner's chosen pressure unit,
365    /// say, has no `pressure_pa` accessor of its own here for the same
366    /// reason `nav-math` itself never grew a pressure type: nothing in
367    /// this workspace holds a pressure reading yet. A future one that
368    /// does calls this directly rather than this crate growing an
369    /// accessor speculatively.
370    #[must_use]
371    pub fn convert(&self, category: &str, si_value: f64) -> Option<(f64, Option<&str>)> {
372        let resolved = self.resolved.get(category)?;
373        Some((si_value * resolved.scale + resolved.offset, resolved.symbol.as_deref()))
374    }
375
376    /// The label `category` converts to, when a preference states one
377    /// this crate reads *and* the server put a word to it -- see
378    /// `Resolved::symbol`'s own doc for why the two are not the same
379    /// question.
380    #[must_use]
381    pub fn symbol(&self, category: &str) -> Option<&str> {
382        self.resolved.get(category)?.symbol.as_deref()
383    }
384
385    /// `nm` in the preferred distance unit, or unchanged with no
386    /// preference stated -- nautical miles already being `nav-math`'s own
387    /// unit, that is a caller's correct fallback regardless. Also what a
388    /// closest point of approach converts through: a CPA is a distance.
389    #[must_use]
390    pub fn distance_nm(&self, nm: f64) -> f64 {
391        self.convert("distance", nm * nav_math::METRES_PER_NM).map_or(nm, |(value, _)| value)
392    }
393
394    /// The distance unit's own symbol -- see [`Self::symbol`]'s own doc
395    /// for exactly when this is `None`.
396    #[must_use]
397    pub fn distance_symbol(&self) -> Option<&str> {
398        self.symbol("distance")
399    }
400
401    /// `kn` in the preferred speed unit, or unchanged with no preference.
402    #[must_use]
403    pub fn speed_kn(&self, kn: f64) -> f64 {
404        // A knot is a nautical mile per hour: the same seam `distance_nm`
405        // crosses, over an hour.
406        let metres_per_second = kn * nav_math::METRES_PER_NM / 3600.0;
407        self.convert("speed", metres_per_second).map_or(kn, |(value, _)| value)
408    }
409
410    /// The speed unit's own symbol -- see [`Self::symbol`]'s own doc for
411    /// exactly when this is `None`.
412    #[must_use]
413    pub fn speed_symbol(&self) -> Option<&str> {
414        self.symbol("speed")
415    }
416
417    /// `metres` in the preferred length unit, or unchanged with no
418    /// preference -- metres already being the wire's own unit for a
419    /// length or a depth, and this crate's own throughout.
420    #[must_use]
421    pub fn length_m(&self, metres: f64) -> f64 {
422        self.convert("length", metres).map_or(metres, |(value, _)| value)
423    }
424
425    /// The length unit's own symbol -- see [`Self::symbol`]'s own doc for
426    /// exactly when this is `None`.
427    #[must_use]
428    pub fn length_symbol(&self) -> Option<&str> {
429        self.symbol("length")
430    }
431
432    /// `metres` in the preferred depth unit, or unchanged with no
433    /// preference -- see [`Self::length_m`]'s own doc, and this module's
434    /// own for why depth is not simply `length_m` under another name.
435    #[must_use]
436    pub fn depth_m(&self, metres: f64) -> f64 {
437        self.convert("depth", metres).map_or(metres, |(value, _)| value)
438    }
439
440    /// The depth unit's own symbol -- see [`Self::symbol`]'s own doc for
441    /// exactly when this is `None`.
442    #[must_use]
443    pub fn depth_symbol(&self) -> Option<&str> {
444        self.symbol("depth")
445    }
446}
447
448/// `symbol` in `lang`'s own words, when this crate knows one -- `symbol`
449/// unchanged otherwise, the same "unrecognised passes through" discipline
450/// [`Preferences::parse`] itself already keeps. `signalk-server` states
451/// every symbol in English regardless of who is reading it (confirmed
452/// live: a mariner's own distance preference set to nautical miles comes
453/// back with `"symbol":"nmi"` whatever locale the request was made
454/// under) -- this is the one place that turns it into something else.
455///
456/// Deliberately not part of [`Preferences`] itself: a caller already has
457/// this crate's own English symbol, from [`Preferences::symbol`] or one
458/// of its four named accessors, and reaches for this only when it wants
459/// a mariner's own words instead, which not every caller does -- one
460/// happy with English survives this table changing without needing to
461/// call anything differently.
462///
463/// `lang` is a BCP-47 language tag, `"de"` or `"de-DE"` alike -- only its
464/// own primary language subtag is read, via [`language_tags`], rather
465/// than this crate learning to split locale strings by hand. An
466/// unparseable `lang` reads the same as a language this crate has
467/// nothing for: `symbol` unchanged. Reading a mariner's own chosen
468/// language at all is not this crate's job, the same "not this crate's
469/// job" boundary [`Preferences`] itself already draws for where a token
470/// lives on disk or which GSettings key a discovery preference is in.
471#[must_use]
472pub fn localized_symbol<'a>(symbol: &'a str, lang: &str) -> &'a str {
473    let Ok(tag) = language_tags::LanguageTag::parse(lang) else {
474        return symbol;
475    };
476    translation(tag.primary_language(), symbol).unwrap_or(symbol)
477}
478
479/// `symbol`'s own word in `lang`, when this crate carries one.
480///
481/// Scoped to the base units [`Preferences`] itself names accessors for
482/// (`m`, `m/s`) rather than every symbol `signalk-server` can ever send
483/// (mass, volume, energy, ... -- see `category_base_unit`'s own list):
484/// nothing in this workspace shows those yet, the same "add an accessor
485/// where it is first wanted" restraint [`Preferences::convert`]'s own
486/// doc already states for the categories beyond nav-core's four
487/// canonical units. Of what is here, `"nmi"`, `"mile"` and `"foot"` are
488/// confirmed live -- the three symbols a real `signalk-server`'s own
489/// nautical and imperial presets actually send for distance/depth/length
490/// (`GET /signalk/v1/unitpreferences/definitions`, checked directly
491/// against a running server rather than its shipped config file, which
492/// turned out to disagree with an older version of this same table:
493/// `"foot"`/`"mile"` spelled out in full, not the `"ft"`/`"mi"` an
494/// earlier `signalk-server` used to send). `"meter"`, `"inch"` and
495/// `"fathom"` are not live-confirmed the same way, but are unambiguous,
496/// standard German words for the identical `m`-based unit, worth having
497/// ready for a custom preset that picks one of them.
498fn translation(lang: &str, symbol: &str) -> Option<&'static str> {
499    match lang {
500        "de" => Some(match symbol {
501            "nmi" => "sm",
502            "mile" => "Meile",
503            "foot" => "Fuß",
504            "meter" => "Meter",
505            "inch" => "Zoll",
506            "fathom" => "Faden",
507            _ => return None,
508        }),
509        _ => None,
510    }
511}
512
513/// `target`'s own `(scale, offset)` out of `base_unit`'s own base, or the
514/// identity when `target` names the base unit itself -- see this module's
515/// own "target-equals-base rule" doc for why that check comes first.
516fn resolve(base_unit: &str, target: &str) -> Option<(f64, f64)> {
517    if target == base_unit {
518        return Some((1.0, 0.0));
519    }
520    conversions(base_unit).iter().find(|row| row.0 == target).map(|row| (row.1, row.2))
521}
522
523#[cfg(test)]
524mod tests {
525    use super::*;
526
527    /// The live answer this module's own doc names, from a real
528    /// `signalk-server` running the `nautical-metric` preset -- confirmed
529    /// against `GET /signalk/v1/unitpreferences/active`.
530    const NAUTICAL_METRIC: &str = r#"{"categories":{
531        "distance":{"targetUnit":"naut-mile","symbol":"nmi"},
532        "speed":{"targetUnit":"kn","symbol":"kn"},
533        "length":{"targetUnit":"m"},
534        "depth":{"targetUnit":"m"},
535        "temperature":{"targetUnit":"C","symbol":"°C"}
536    }}"#;
537
538    #[test]
539    fn a_live_nautical_metric_answer_is_read() {
540        let preferences = Preferences::parse(&serde_json::from_str(NAUTICAL_METRIC).unwrap());
541        assert_eq!(preferences.distance_symbol(), Some("nmi"));
542        assert_eq!(preferences.speed_symbol(), Some("kn"));
543        // Distance's own target-equals-base rule applies here ("naut-mile"
544        // is `m`'s own identity spelling for distance), so it round-trips
545        // exactly. Speed's does not: "kn" is a genuine target on the `m/s`
546        // base, routed through the table like any other, which carries
547        // signalk-server's own mildly rounded 1.94384 rather than the
548        // exact 1852/3600 -- see this module's own dedicated test for that
549        // factor. A mariner whose preference literally says "kn" sees this
550        // same rounding in every other figure the server shows too, so
551        // matching it here is the honest answer, not a looser one of ours.
552        assert!((preferences.distance_nm(12.0) - 12.0).abs() < 1e-9);
553        assert!((preferences.speed_kn(6.0) - 6.0).abs() < 1e-4);
554    }
555
556    #[test]
557    fn depth_is_read_apart_from_length_even_when_the_two_agree() {
558        let preferences = Preferences::parse(&serde_json::from_str(NAUTICAL_METRIC).unwrap());
559        assert!((preferences.depth_m(4.0) - 4.0).abs() < 1e-9);
560    }
561
562    #[test]
563    fn a_custom_preset_may_put_depth_and_length_in_different_units() {
564        // Nothing built in ever does this, but a custom preset is free
565        // to, and the two must not be read as if they had to agree.
566        let value = serde_json::json!({"categories": {
567            "length": {"targetUnit": "m"},
568            "depth": {"targetUnit": "foot", "symbol": "foot"},
569        }});
570        let preferences = Preferences::parse(&value);
571        assert_eq!(preferences.length_symbol(), None);
572        assert_eq!(preferences.depth_symbol(), Some("foot"));
573        assert!((preferences.length_m(1.0) - 1.0).abs() < 1e-9);
574        assert!((preferences.depth_m(1.0) - 3.280_839_895).abs() < 1e-6);
575    }
576
577    #[test]
578    fn a_length_that_maps_to_the_base_units_own_name_is_read_with_no_symbol() {
579        // Confirmed live: `signalk-server`'s own `/active` sends the
580        // literal targetUnit "m" for "no conversion", not "meter" (the
581        // spelling `conversions("m")` itself uses), and never a `symbol`
582        // for it -- see this module's own "target-equals-base rule" doc.
583        let preferences = Preferences::parse(&serde_json::from_str(NAUTICAL_METRIC).unwrap());
584        assert_eq!(preferences.length_symbol(), None);
585        assert!((preferences.length_m(2.0) - 2.0).abs() < 1e-9);
586    }
587
588    #[test]
589    fn an_explicitly_blank_symbol_reads_the_same_as_none_stated() {
590        let value = serde_json::json!({"categories": {
591            "distance": {"targetUnit": "kilometer", "symbol": ""},
592        }});
593        let preferences = Preferences::parse(&value);
594        assert_eq!(preferences.distance_symbol(), None);
595    }
596
597    #[test]
598    fn metric_converts_distance_to_kilometres_and_speed_to_kmh() {
599        let value = serde_json::json!({"categories": {
600            "distance": {"targetUnit": "kilometer", "symbol": "km"},
601            "speed": {"targetUnit": "km/h", "symbol": "km/h"},
602        }});
603        let preferences = Preferences::parse(&value);
604        // One nautical mile is 1.852 km, confirmed against the fixed
605        // definition nav-math states.
606        assert!((preferences.distance_nm(1.0) - 1.852).abs() < 1e-9);
607        assert!((preferences.speed_kn(1.0) - 1.852).abs() < 1e-9);
608    }
609
610    #[test]
611    fn imperial_uk_converts_distance_to_statute_miles_and_length_to_feet() {
612        let value = serde_json::json!({"categories": {
613            "distance": {"targetUnit": "mile", "symbol": "mile"},
614            "length": {"targetUnit": "foot", "symbol": "foot"},
615        }});
616        let preferences = Preferences::parse(&value);
617        // A nautical mile is about 1.15078 statute miles.
618        assert!((preferences.distance_nm(1.0) - 1.150_779_45).abs() < 1e-6);
619        // A metre is about 3.28084 feet.
620        assert!((preferences.length_m(1.0) - 3.280_839_895).abs() < 1e-6);
621    }
622
623    #[test]
624    fn a_speed_in_knots_matches_the_servers_own_slightly_rounded_factor_when_named_explicitly() {
625        // Unlike the identity case above, spelling "kn" out as speed's
626        // *target* on a base unit of m/s is read the same as any other
627        // target, through the server's own shipped (and mildly rounded)
628        // factor. This crate reproduces that factor exactly rather than a
629        // more precise one of its own, because matching what the
630        // mariner's own server actually computes is the point, not being
631        // more accurate than it. Checked through `convert` directly, not
632        // `speed_kn`, whose own identity fast path this is deliberately
633        // not exercising.
634        let value = serde_json::json!({"categories": {
635            "speed": {"targetUnit": "kn", "symbol": "kn"},
636        }});
637        let preferences = Preferences::parse(&value);
638        let (converted, _) = preferences.convert("speed", 10.0).expect("a reading");
639        // 10 m/s at the server's own 1.94384 (not the exact 1.9438444...).
640        assert!((converted - 19.4384).abs() < 1e-9);
641    }
642
643    #[test]
644    fn an_unrecognised_target_unit_leaves_the_category_absent() {
645        let value = serde_json::json!({"categories": {
646            "distance": {"targetUnit": "smoot", "symbol": "smoot"},
647        }});
648        let preferences = Preferences::parse(&value);
649        assert_eq!(preferences.distance_symbol(), None);
650        // The caller's own fallback: unchanged, in nav-math's own unit.
651        assert!((preferences.distance_nm(5.0) - 5.0).abs() < 1e-9);
652    }
653
654    #[test]
655    fn beaufort_is_not_a_reading_this_crate_understands() {
656        let value = serde_json::json!({"categories": {"speed": {"targetUnit": "Bf", "symbol": "Bf"}}});
657        let preferences = Preferences::parse(&value);
658        assert_eq!(preferences.speed_symbol(), None);
659        assert!((preferences.speed_kn(4.0) - 4.0).abs() < 1e-9);
660    }
661
662    #[test]
663    fn a_duration_formatter_is_not_a_reading_this_crate_understands() {
664        let value = serde_json::json!({"categories": {"time": {"targetUnit": "HH:MM:SS"}}});
665        let preferences = Preferences::parse(&value);
666        assert_eq!(preferences.convert("time", 3661.0), None);
667    }
668
669    #[test]
670    fn an_answer_with_no_categories_at_all_is_every_preference_absent() {
671        let preferences = Preferences::parse(&serde_json::json!({}));
672        assert_eq!(preferences, Preferences::default());
673        assert_eq!(preferences.convert("distance", 5.0), None);
674    }
675
676    #[test]
677    fn a_category_this_crate_does_not_recognise_converts_nothing() {
678        let value = serde_json::json!({"categories": {"madeUpCategory": {"targetUnit": "x"}}});
679        let preferences = Preferences::parse(&value);
680        assert_eq!(preferences.convert("madeUpCategory", 1.0), None);
681    }
682
683    #[test]
684    fn temperature_converts_kelvin_to_celsius_and_fahrenheit() {
685        let celsius = Preferences::parse(&serde_json::json!({"categories": {
686            "temperature": {"targetUnit": "C", "symbol": "°C"},
687        }}));
688        let (value, symbol) = celsius.convert("temperature", 300.0).expect("a reading");
689        assert!((value - 26.85).abs() < 1e-9);
690        assert_eq!(symbol, Some("\u{b0}C"));
691
692        let fahrenheit = Preferences::parse(&serde_json::json!({"categories": {
693            "temperature": {"targetUnit": "F", "symbol": "°F"},
694        }}));
695        let (value, _) = fahrenheit.convert("temperature", 300.0).expect("a reading");
696        assert!((value - 80.33).abs() < 1e-9);
697    }
698
699    #[test]
700    fn generic_convert_reaches_every_category_this_module_names_a_base_unit_for() {
701        // Every entry `category_base_unit` answers is reachable through
702        // the one generic path, at the identity, with no preference
703        // stated -- the same fallback contract as the four named
704        // accessors, without having to write one accessor per category.
705        let preferences = Preferences::default();
706        for category in [
707            "speed", "temperature", "pressure", "distance", "depth", "angle", "angleDegrees",
708            "angularVelocity", "volume", "voltage", "current", "power", "percentage", "frequency",
709            "time", "charge", "volumeRate", "length", "energy", "mass", "area", "dateTime", "epoch",
710            "unitless", "boolean", "dataSize",
711        ] {
712            assert_eq!(preferences.convert(category, 7.0), None, "{category}");
713        }
714    }
715
716    #[test]
717    fn the_conversion_table_matches_a_real_servers_own_answer_for_every_base_unit_it_covers() {
718        // The fixture is `GET /signalk/v1/unitpreferences/definitions`'s
719        // own answer, confirmed live -- see this module's own top doc.
720        // This test is what notices signalk-server having added a target
721        // unit this table does not yet have a row for: a new one shows up
722        // in the fixture (once it is refreshed against a newer server --
723        // see the crate's own README) and immediately fails here, named,
724        // rather than silently being read as "unrecognised" forever.
725        let fixture: Value = serde_json::from_str(include_str!("../testdata/unitpreferences.json")).unwrap();
726        let definitions = fixture.get("definitions").and_then(Value::as_object).expect("a definitions object");
727
728        // Every affine target this crate's own table does not carry is
729        // named here, with the one word that makes it so -- checked
730        // against, not merely trusted, so a fixture refresh that quietly
731        // stopped naming one of these would fail loudly too.
732        let unsupported = [
733            ("m/s", "Bf"),
734            ("s", "DD:HH:MM:SS"),
735            ("s", "HH:MM:SS"),
736            ("s", "HH:MM:SS.mmm"),
737            ("s", "MM:SS"),
738            ("s", "MM:SS.mmm"),
739            ("s", "duration-verbose"),
740            ("s", "duration-compact"),
741        ];
742
743        let mut missing = Vec::new();
744        for (base, info) in definitions {
745            let conversions = info.get("conversions").and_then(Value::as_object).expect("conversions");
746            for (target, conversion) in conversions {
747                if target == base {
748                    continue; // the target-equals-base identity, not a table row
749                }
750                if unsupported.contains(&(base.as_str(), target.as_str())) {
751                    continue;
752                }
753                let formula = conversion.get("formula").and_then(Value::as_str).unwrap_or_default();
754                let Some((want_scale, want_offset)) = parse_affine_formula(formula) else {
755                    missing.push(format!(
756                        "{base} -> {target}: formula {formula:?} is not affine and is not on the unsupported list"
757                    ));
758                    continue;
759                };
760                match table_lookup(base, target) {
761                    Some((scale, offset)) => {
762                        assert!(
763                            (scale - want_scale).abs() < 1e-9 && (offset - want_offset).abs() < 1e-9,
764                            "{base} -> {target}: table has ({scale}, {offset}), fixture says ({want_scale}, {want_offset})"
765                        );
766                    }
767                    None => missing.push(format!("{base} -> {target}: not in conversions(), and not on the unsupported list")),
768                }
769            }
770        }
771        assert!(missing.is_empty(), "signalk-server names targets this table does not:\n{}", missing.join("\n"));
772    }
773
774    /// The same `value * scale + offset` shape `conversions`' own rows
775    /// are computed as, read back out of a live `formula` string -- used
776    /// only by the fixture-coverage test above, to turn its ground truth
777    /// into the same shape this module's own table already carries,
778    /// never at runtime.
779    fn parse_affine_formula(formula: &str) -> Option<(f64, f64)> {
780        let formula = formula.trim();
781        if formula == "value" {
782            return Some((1.0, 0.0));
783        }
784        if let Some(factor) = formula.strip_prefix("value * ") {
785            return factor.parse().ok().map(|scale| (scale, 0.0));
786        }
787        if let Some(divisor) = formula.strip_prefix("value / ") {
788            return divisor.parse::<f64>().ok().map(|divisor| (1.0 / divisor, 0.0));
789        }
790        if let Some(subtrahend) = formula.strip_prefix("value - ") {
791            return subtrahend.parse().ok().map(|offset: f64| (1.0, -offset));
792        }
793        // "(value - X) * Y + Z" -- Fahrenheit's own shape, the only one
794        // of this form in the whole catalogue.
795        let rest = formula.strip_prefix("(value - ")?;
796        let (subtrahend, rest) = rest.split_once(") * ")?;
797        let (scale, addend) = rest.split_once(" + ")?;
798        let subtrahend: f64 = subtrahend.parse().ok()?;
799        let scale: f64 = eval_simple_fraction(scale)?;
800        let addend: f64 = addend.parse().ok()?;
801        Some((scale, -subtrahend * scale + addend))
802    }
803
804    /// `"9/5"` or a bare number -- the only two shapes a `scale` in
805    /// [`parse_affine_formula`] is ever written in.
806    fn eval_simple_fraction(text: &str) -> Option<f64> {
807        match text.split_once('/') {
808            Some((num, den)) => Some(num.parse::<f64>().ok()? / den.parse::<f64>().ok()?),
809            None => text.parse().ok(),
810        }
811    }
812
813    /// `conversions`, read the same way [`Preferences::convert`] reads
814    /// it -- `pub(crate)` would do, but the fixture test above is the
815    /// only caller and lives in this same module.
816    fn table_lookup(base: &str, target: &str) -> Option<(f64, f64)> {
817        conversions(base).iter().find(|row| row.0 == target).map(|row| (row.1, row.2))
818    }
819
820    #[test]
821    fn a_known_symbol_is_read_in_german() {
822        assert_eq!(localized_symbol("nmi", "de"), "sm");
823        assert_eq!(localized_symbol("mile", "de"), "Meile");
824        assert_eq!(localized_symbol("foot", "de"), "Fuß");
825    }
826
827    #[test]
828    fn a_full_locale_tag_reads_by_its_own_primary_language_alone() {
829        assert_eq!(localized_symbol("nmi", "de-DE"), "sm");
830        assert_eq!(localized_symbol("nmi", "de-AT"), "sm");
831    }
832
833    #[test]
834    fn an_unrecognised_symbol_passes_through_unchanged() {
835        // "kn" and "km/h" are already the same in German -- not because
836        // this crate translated them, but because it was never asked to:
837        // no entry means the original string, unrecognised is skipped,
838        // not refused, the same discipline `Preferences::parse` keeps.
839        assert_eq!(localized_symbol("kn", "de"), "kn");
840        assert_eq!(localized_symbol("furlong", "de"), "furlong");
841    }
842
843    #[test]
844    fn a_language_this_crate_has_nothing_for_passes_symbols_through() {
845        assert_eq!(localized_symbol("nmi", "fr"), "nmi");
846    }
847
848    #[test]
849    fn an_unparseable_locale_tag_is_treated_the_same_as_an_unknown_one() {
850        assert_eq!(localized_symbol("nmi", "not a locale"), "nmi");
851        assert_eq!(localized_symbol("nmi", ""), "nmi");
852    }
853}