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}