Skip to content

Commit a72fd40

Browse files
committed
feat(unitpreferences): name the path override in resolved metadata
A resolved displayUnits response reads the same whether the target unit came from the path or from the active preset, so a client editing the metadata cannot tell one from the other, and one saving it back cannot say which fields the path owns. The response now carries an override object naming the target unit and display format the path itself chose, empty when the path follows the preset. A metadata PUT that carries it is taken at its word, which is a plainer answer than reading an echo by its shape.
1 parent bb911b5 commit a72fd40

4 files changed

Lines changed: 142 additions & 30 deletions

File tree

docs/guides/unitpreferences.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,8 @@ Request the metadata for any path:
116116
"formula": "value * 1.94384",
117117
"inverseFormula": "value / 1.94384",
118118
"symbol": "kn",
119-
"displayFormat": "0.0"
119+
"displayFormat": "0.0",
120+
"override": {}
120121
}
121122
}
122123
```
@@ -129,6 +130,7 @@ The `displayUnits` object provides everything you need to display the value:
129130
- **inverseFormula**: A Math.js expression to convert back from the display unit to SI (useful for user input).
130131
- **symbol**: The symbol to display next to the value.
131132
- **displayFormat**: (Optional) A format pattern for consistency (e.g., "0.0" for one decimal place).
133+
- **override**: The part of the answer the path itself chose rather than the active preset, as `targetUnit` and `displayFormat`. Empty when the path follows the preset. Display code can ignore this; an editor needs it to tell "knots because this path asks for knots" from "knots because the preset says so". A `PUT` of the whole object is read the same way, so saving a response back does not turn the preset's current choices into a path override.
132134

133135
### WebSocket Stream
134136

@@ -173,7 +175,8 @@ ws.onopen = () => {
173175
"formula": "value * 1.94384",
174176
"inverseFormula": "value / 1.94384",
175177
"symbol": "kn",
176-
"displayFormat": "0.0"
178+
"displayFormat": "0.0",
179+
"override": {}
177180
}
178181
}
179182
}

src/unitpreferences/resolver.ts

Lines changed: 75 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,32 @@ import {
44
getActivePreset,
55
getActivePresetForUser
66
} from './loader'
7-
import { EnhancedDisplayUnits, DisplayUnitsMetadata } from './types'
7+
import {
8+
EnhancedDisplayUnits,
9+
DisplayUnitsMetadata,
10+
DisplayUnitsOverride
11+
} from './types'
12+
13+
/**
14+
* What the path itself chose, as opposed to what the preset supplied.
15+
*
16+
* A response this server resolved carries that answer in its `override`
17+
* field; anything else is the stored metadata, where every field present is
18+
* the path's own.
19+
*/
20+
function pathOverride(
21+
displayUnits: DisplayUnitsMetadata
22+
): DisplayUnitsOverride {
23+
const owned = displayUnits.override ?? displayUnits
24+
const override: DisplayUnitsOverride = {}
25+
if (owned.targetUnit !== undefined) {
26+
override.targetUnit = owned.targetUnit
27+
}
28+
if (owned.displayFormat !== undefined) {
29+
override.displayFormat = owned.displayFormat
30+
}
31+
return override
32+
}
833

934
/**
1035
* Given stored displayUnits metadata, resolve the full conversion info
@@ -24,6 +49,18 @@ export function resolveDisplayUnits(
2449
}
2550

2651
const category = storedDisplayUnits.category
52+
const override = pathOverride(storedDisplayUnits)
53+
// Resolving a response this server already resolved is the same job as
54+
// resolving the stored metadata it came from.
55+
const stored: DisplayUnitsMetadata = storedDisplayUnits.override
56+
? {
57+
category,
58+
...override,
59+
formula: storedDisplayUnits.formula,
60+
inverseFormula: storedDisplayUnits.inverseFormula,
61+
symbol: storedDisplayUnits.symbol
62+
}
63+
: storedDisplayUnits
2764

2865
// "base" category means display in SI units without conversion
2966
if (category === 'base') {
@@ -33,50 +70,54 @@ export function resolveDisplayUnits(
3370
formula: 'value',
3471
inverseFormula: 'value',
3572
symbol: pathSiUnit || '',
36-
displayFormat: undefined
73+
displayFormat: undefined,
74+
override
3775
}
3876
}
3977

4078
// "custom" category stores explicit conversion info
4179
if (category === 'custom') {
42-
if (!storedDisplayUnits.targetUnit) {
80+
if (!stored.targetUnit) {
4381
return null
4482
}
4583
// Identity conversion: targetUnit matches the path's SI unit
46-
if (pathSiUnit && storedDisplayUnits.targetUnit === pathSiUnit) {
84+
if (pathSiUnit && stored.targetUnit === pathSiUnit) {
4785
return {
4886
category: 'custom',
49-
targetUnit: storedDisplayUnits.targetUnit,
87+
targetUnit: stored.targetUnit,
5088
formula: 'value',
5189
inverseFormula: 'value',
52-
symbol: storedDisplayUnits.symbol || storedDisplayUnits.targetUnit,
53-
displayFormat: storedDisplayUnits.displayFormat
90+
symbol: stored.symbol || stored.targetUnit,
91+
displayFormat: stored.displayFormat,
92+
override
5493
}
5594
}
5695
// If formula is stored, use it directly
57-
if (storedDisplayUnits.formula) {
96+
if (stored.formula) {
5897
return {
5998
category: 'custom',
60-
targetUnit: storedDisplayUnits.targetUnit,
61-
formula: storedDisplayUnits.formula,
62-
inverseFormula: storedDisplayUnits.inverseFormula || '',
63-
symbol: storedDisplayUnits.symbol || storedDisplayUnits.targetUnit,
64-
displayFormat: storedDisplayUnits.displayFormat
99+
targetUnit: stored.targetUnit,
100+
formula: stored.formula,
101+
inverseFormula: stored.inverseFormula || '',
102+
symbol: stored.symbol || stored.targetUnit,
103+
displayFormat: stored.displayFormat,
104+
override
65105
}
66106
}
67107
// Otherwise look up from definitions using pathSiUnit
68108
if (pathSiUnit) {
69109
const definitions = getMergedDefinitions()
70110
const conversion =
71-
definitions[pathSiUnit]?.conversions?.[storedDisplayUnits.targetUnit]
111+
definitions[pathSiUnit]?.conversions?.[stored.targetUnit]
72112
if (conversion) {
73113
return {
74114
category: 'custom',
75-
targetUnit: storedDisplayUnits.targetUnit,
115+
targetUnit: stored.targetUnit,
76116
formula: conversion.formula,
77117
inverseFormula: conversion.inverseFormula,
78-
symbol: conversion.symbol || storedDisplayUnits.targetUnit,
79-
displayFormat: storedDisplayUnits.displayFormat
118+
symbol: conversion.symbol || stored.targetUnit,
119+
displayFormat: stored.displayFormat,
120+
override
80121
}
81122
}
82123
}
@@ -96,8 +137,8 @@ export function resolveDisplayUnits(
96137
// Step 2: Determine target unit
97138
// Priority: path override > preset default
98139
let targetUnit: string
99-
if (storedDisplayUnits.targetUnit) {
100-
targetUnit = storedDisplayUnits.targetUnit
140+
if (stored.targetUnit) {
141+
targetUnit = stored.targetUnit
101142
} else if (preset?.categories?.[category]?.targetUnit) {
102143
targetUnit = preset.categories[category].targetUnit
103144
} else {
@@ -113,8 +154,8 @@ export function resolveDisplayUnits(
113154
inverseFormula: 'value',
114155
symbol: siUnit,
115156
displayFormat:
116-
storedDisplayUnits.displayFormat ||
117-
preset?.categories?.[category]?.displayFormat
157+
stored.displayFormat || preset?.categories?.[category]?.displayFormat,
158+
override
118159
}
119160
}
120161

@@ -137,8 +178,8 @@ export function resolveDisplayUnits(
137178
inverseFormula: conversion.inverseFormula,
138179
symbol: conversion.symbol,
139180
displayFormat:
140-
storedDisplayUnits.displayFormat ||
141-
preset?.categories?.[category]?.displayFormat
181+
stored.displayFormat || preset?.categories?.[category]?.displayFormat,
182+
override
142183
}
143184
}
144185

@@ -148,10 +189,11 @@ export function resolveDisplayUnits(
148189
* Clients read metadata back resolved — target unit, formulas and format
149190
* filled in from the active preset — so writing it back verbatim would store
150191
* the preset's current choices as a path override and detach the path from
151-
* the preset. The resolved shape carries a formula and the stored shape does
152-
* not, which is what tells an echo of the resolution apart from an override
153-
* the client states itself. In an echo, a value the preset would have
154-
* produced anyway is dropped unless the path already stored it.
192+
* the preset. A resolved response names the path's own choices in its
193+
* `override` field, which settles the question outright. A client that sends
194+
* neither is read by shape: the resolved shape carries a formula and the
195+
* stored shape does not, and in a formula-carrying echo a value the preset
196+
* would have produced anyway is dropped unless the path already stored it.
155197
*
156198
* @param incoming - displayUnits as received in a metadata PUT
157199
* @param previous - displayUnits currently stored for the path
@@ -172,12 +214,17 @@ export function stripResolvedDisplayUnits(
172214
// A custom unit is nothing but its explicit conversion, and "base" needs
173215
// no conversion at all.
174216
if (category === 'custom') {
175-
return incoming
217+
const { override: _override, ...stored } = incoming
218+
return stored
176219
}
177220
if (category === 'base') {
178221
return { category }
179222
}
180223

224+
if (incoming.override) {
225+
return { category, ...incoming.override }
226+
}
227+
181228
const echoesResolution = incoming.formula !== undefined
182229
const preset = username ? getActivePresetForUser(username) : getActivePreset()
183230
const presetCategory = preset?.categories?.[category]

src/unitpreferences/types.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,14 @@ export interface DisplayUnitsMetadata {
6666
inverseFormula?: string // Only if custom category
6767
symbol?: string // Only if custom category
6868
displayFormat?: string // Only if path override
69+
override?: DisplayUnitsOverride // Only on the way out, never stored
70+
}
71+
72+
// The part of a resolved response the path itself chose, as opposed to the
73+
// part the active preset supplied. Empty when the path follows the preset.
74+
export interface DisplayUnitsOverride {
75+
targetUnit?: string
76+
displayFormat?: string
6977
}
7078

7179
// What server returns in GET /meta response
@@ -76,4 +84,5 @@ export interface EnhancedDisplayUnits {
7684
inverseFormula: string
7785
symbol: string
7886
displayFormat?: string
87+
override: DisplayUnitsOverride
7988
}

test/metadata-e2e.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,13 @@ describe('Display unit metadata', function () {
250250
await new Promise((resolve) => setTimeout(resolve, 200))
251251
}
252252

253+
const servedDisplayUnits = async (skPath = SPEED_PATH_SLASHES) => {
254+
const meta = await fetch(`${v1Api}/vessels/self/${skPath}/meta`).then((r) =>
255+
r.json()
256+
)
257+
return meta.displayUnits
258+
}
259+
253260
const storedDisplayUnits = (): DisplayUnitsMetadata | undefined => {
254261
const deltas = JSON.parse(
255262
fs.readFileSync(
@@ -341,6 +348,52 @@ describe('Display unit metadata', function () {
341348
expect(storedDisplayUnits()).to.deep.equal({ category: 'speed' })
342349
})
343350

351+
it('names the path override in the resolved metadata', async () => {
352+
await putSpeedMeta({ category: 'speed', targetUnit: 'm/s' })
353+
expect(await servedDisplayUnits()).to.include({
354+
targetUnit: 'm/s',
355+
symbol: 'm/s'
356+
})
357+
expect((await servedDisplayUnits()).override).to.deep.equal({
358+
targetUnit: 'm/s'
359+
})
360+
})
361+
362+
it('leaves the override empty for a path that follows the preset', async () => {
363+
const displayUnits = await servedDisplayUnits('navigation/speedOverGround')
364+
expect(displayUnits.targetUnit).to.equal(PRESET_SPEED_UNIT)
365+
expect(displayUnits.override).to.deep.equal({})
366+
})
367+
368+
it('keeps the override a resolved response is saved back with', async () => {
369+
await putSpeedMeta({
370+
category: 'speed',
371+
targetUnit: PRESET_SPEED_UNIT,
372+
formula: 'value * 1.94384',
373+
inverseFormula: 'value * 0.514444',
374+
symbol: PRESET_SPEED_UNIT,
375+
displayFormat: '0.0',
376+
override: { targetUnit: PRESET_SPEED_UNIT }
377+
})
378+
expect(storedDisplayUnits()).to.deep.equal({
379+
category: 'speed',
380+
targetUnit: PRESET_SPEED_UNIT
381+
})
382+
})
383+
384+
it('drops what a resolved response says the path does not own', async () => {
385+
await putSpeedMeta({
386+
category: 'speed',
387+
targetUnit: 'm/s',
388+
formula: 'value',
389+
inverseFormula: 'value',
390+
symbol: 'm/s',
391+
displayFormat: '0.0',
392+
override: {}
393+
})
394+
expect(storedDisplayUnits()).to.deep.equal({ category: 'speed' })
395+
})
396+
344397
it('sends the resolved conversion to connected clients', async () => {
345398
const receiver = new WsPromiser(
346399
`ws://localhost:${port}/signalk/v1/stream?subscribe=self&sendMeta=all&sendCachedValues=false`,

0 commit comments

Comments
 (0)