@oicl/openbridge-webcomponents-full-bundle 2.0.0-next.112 → 2.0.0-next.113

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/bundle/openbridge-webcomponents.bundle.js +112 -13
  2. package/bundle/openbridge-webcomponents.bundle.js.map +1 -1
  3. package/custom-elements.json +101 -14
  4. package/dist/building-blocks/readout-block/readout-block.d.ts +29 -5
  5. package/dist/building-blocks/readout-block/readout-block.d.ts.map +1 -1
  6. package/dist/building-blocks/readout-block/readout-block.js +21 -7
  7. package/dist/building-blocks/readout-block/readout-block.js.map +1 -1
  8. package/dist/navigation-instruments/readout/readout-formatters.d.ts +31 -0
  9. package/dist/navigation-instruments/readout/readout-formatters.d.ts.map +1 -1
  10. package/dist/navigation-instruments/readout/readout-formatters.js +55 -1
  11. package/dist/navigation-instruments/readout/readout-formatters.js.map +1 -1
  12. package/dist/navigation-instruments/readout/readout.d.ts +26 -1
  13. package/dist/navigation-instruments/readout/readout.d.ts.map +1 -1
  14. package/dist/navigation-instruments/readout/readout.js +16 -3
  15. package/dist/navigation-instruments/readout/readout.js.map +1 -1
  16. package/dist/navigation-instruments/readout-list/readout-list.d.ts +6 -1
  17. package/dist/navigation-instruments/readout-list/readout-list.d.ts.map +1 -1
  18. package/dist/navigation-instruments/readout-list/readout-list.js +21 -4
  19. package/dist/navigation-instruments/readout-list/readout-list.js.map +1 -1
  20. package/dist/navigation-instruments/readout-list-item/readout-list-item.d.ts +26 -1
  21. package/dist/navigation-instruments/readout-list-item/readout-list-item.d.ts.map +1 -1
  22. package/dist/navigation-instruments/readout-list-item/readout-list-item.js +15 -3
  23. package/dist/navigation-instruments/readout-list-item/readout-list-item.js.map +1 -1
  24. package/package.json +1 -1
  25. package/src/building-blocks/readout-block/readout-block.stories.ts +112 -3
  26. package/src/building-blocks/readout-block/readout-block.ts +64 -10
  27. package/src/navigation-instruments/readout/readout-formatters.spec.ts +229 -0
  28. package/src/navigation-instruments/readout/readout-formatters.ts +105 -0
  29. package/src/navigation-instruments/readout/readout.stories.ts +167 -3
  30. package/src/navigation-instruments/readout/readout.ts +52 -4
  31. package/src/navigation-instruments/readout-list/readout-list.stories.ts +122 -1
  32. package/src/navigation-instruments/readout-list/readout-list.ts +54 -5
  33. package/src/navigation-instruments/readout-list-item/readout-list-item.stories.ts +124 -4
  34. package/src/navigation-instruments/readout-list-item/readout-list-item.ts +53 -4
@@ -1,11 +1,13 @@
1
1
  import type {Meta, StoryObj} from '@storybook/web-components-vite';
2
2
  import {html, nothing} from 'lit';
3
+ import {expect} from 'storybook/test';
3
4
  import {
4
5
  ReadoutBlockVariant,
5
6
  ReadoutBlockSize,
6
7
  ReadoutBlockDataQuality,
7
8
  ObcTextboxFontWeight,
8
9
  ObcTextboxAlignment,
10
+ ReadoutValueType,
9
11
  } from './readout-block.js';
10
12
  import './readout-block.js';
11
13
  import '../../icons/icon-placeholder.js';
@@ -19,7 +21,8 @@ const NONE = 'none';
19
21
 
20
22
  type BlockArgs = {
21
23
  variant: ReadoutBlockVariant;
22
- value: number;
24
+ value: number | string | null;
25
+ valueType: ReadoutValueType;
23
26
  size: ReadoutBlockSize;
24
27
  enhanced: boolean;
25
28
  weight: ObcTextboxFontWeight;
@@ -43,6 +46,7 @@ function renderBlock(args: Partial<BlockArgs>) {
43
46
  <obc-readout-block
44
47
  .variant=${args.variant ?? ReadoutBlockVariant.value}
45
48
  .value=${args.value ?? null}
49
+ .valueType=${args.valueType ?? ReadoutValueType.number}
46
50
  .size=${args.size ?? ReadoutBlockSize.small}
47
51
  .enhanced=${args.enhanced ?? false}
48
52
  .weight=${args.weight ?? ObcTextboxFontWeight.regular}
@@ -128,6 +132,7 @@ const meta = {
128
132
  args: {
129
133
  variant: ReadoutBlockVariant.value,
130
134
  value: 123,
135
+ valueType: ReadoutValueType.number,
131
136
  size: ReadoutBlockSize.small,
132
137
  enhanced: false,
133
138
  weight: ObcTextboxFontWeight.regular,
@@ -143,7 +148,14 @@ const meta = {
143
148
  dataQuality: NONE,
144
149
  },
145
150
  argTypes: {
146
- value: {control: {type: 'number'}},
151
+ // Text control (not number) so both value types are exercisable. Under
152
+ // valueType=number a numeric string resolves back to a number; entering
153
+ // non-numeric text there throws, which is the intended contract.
154
+ value: {control: {type: 'text'}},
155
+ valueType: {
156
+ control: {type: 'inline-radio'},
157
+ options: Object.values(ReadoutValueType),
158
+ },
147
159
  variant: {
148
160
  control: {type: 'select'},
149
161
  options: Object.values(ReadoutBlockVariant),
@@ -243,6 +255,66 @@ export const OffText: Story = {
243
255
  ]),
244
256
  };
245
257
 
258
+ /**
259
+ * `valueType="text"` renders `value` verbatim instead of formatting it as a
260
+ * number — for readings that are states rather than quantities.
261
+ *
262
+ * The numeric format options (`fractionDigits`, `maxDigits`, `hintedZeros`) are
263
+ * ignored in this mode; an explicit `spaceReserver` still applies. Passing text
264
+ * while `valueType` is `number` throws a `TypeError` rather than rendering
265
+ * `NaN`, while a numeric-looking string such as `"12.4"` is accepted and parsed
266
+ * so plain-HTML `value="12.4"` keeps working.
267
+ */
268
+ export const TextValue: Story = {
269
+ render: () =>
270
+ renderShowcase([
271
+ {
272
+ title: 'text',
273
+ args: {value: 'Auto', valueType: ReadoutValueType.text},
274
+ },
275
+ {
276
+ title: 'longer text',
277
+ args: {value: 'Thermo On', valueType: ReadoutValueType.text},
278
+ },
279
+ {
280
+ title: 'verbatim "1.50"',
281
+ args: {
282
+ value: '1.50',
283
+ valueType: ReadoutValueType.text,
284
+ fractionDigits: 1,
285
+ },
286
+ },
287
+ {
288
+ title: 'maxDigits ignored',
289
+ args: {value: 'Auto', valueType: ReadoutValueType.text, maxDigits: 4},
290
+ },
291
+ {
292
+ title: 'spaceReserver honoured',
293
+ args: {
294
+ value: 'Auto',
295
+ valueType: ReadoutValueType.text,
296
+ spaceReserver: 'Thermo On',
297
+ },
298
+ },
299
+ {
300
+ title: 'null → dash',
301
+ args: {value: null, valueType: ReadoutValueType.text},
302
+ },
303
+ {
304
+ title: 'numeric string in number mode',
305
+ args: {value: '12.4', fractionDigits: 1},
306
+ },
307
+ {
308
+ title: 'text + degree',
309
+ args: {
310
+ value: 'Auto',
311
+ valueType: ReadoutValueType.text,
312
+ hasDegree: true,
313
+ },
314
+ },
315
+ ]),
316
+ };
317
+
246
318
  /**
247
319
  * Hinted zeros pad the integer part up to `maxDigits` as muted leading zeros.
248
320
  * When enabled they take priority over `spaceReserver` (they already fill to
@@ -306,6 +378,43 @@ export const Alignment: Story = {
306
378
  ),
307
379
  };
308
380
 
381
+ /**
382
+ * Regression test for a validation hole, not a visual case.
383
+ *
384
+ * When `willUpdate` throws, Lit's `performUpdate` catch calls `__markUpdated()`,
385
+ * which clears the changed-properties map. Validation used to be gated on
386
+ * `changed.has('value') || changed.has('valueType')`, so the NEXT update —
387
+ * driven by any other property, e.g. `obc-readout-list.align()` writing the
388
+ * shared reservers — saw an empty map, skipped the check, and rendered the
389
+ * invalid value as a plain dash. Loud once, then silent forever.
390
+ *
391
+ * An EMPTY changed map is exactly what Lit leaves behind after a throw, so
392
+ * `willUpdate` is invoked directly with one. The element is deliberately left
393
+ * detached: an unconnected `LitElement` never starts its update cycle, so this
394
+ * exercises the guard without the real throw escaping the scheduler as an
395
+ * unhandled rejection.
396
+ */
397
+ export const TestValidationSurvivesUnrelatedUpdate: Story = {
398
+ render: () => html`<span>Regression test — see the play function.</span>`,
399
+ play: async () => {
400
+ type Probe = HTMLElement & {
401
+ value: number | string | null;
402
+ willUpdate: (changed: Map<string, unknown>) => void;
403
+ };
404
+ const el = document.createElement('obc-readout-block') as Probe;
405
+ const validateWithNoChanges = () => el.willUpdate(new Map());
406
+
407
+ el.value = 'Auto';
408
+ await expect(validateWithNoChanges).toThrow(/value must be a number/);
409
+
410
+ el.value = 12.4;
411
+ await expect(validateWithNoChanges).not.toThrow();
412
+
413
+ el.value = null;
414
+ await expect(validateWithNoChanges).not.toThrow();
415
+ },
416
+ };
417
+
309
418
  export const DataQuality: Story = {
310
419
  render: () =>
311
420
  renderShowcase([
@@ -318,7 +427,7 @@ export const DataQuality: Story = {
318
427
  title: 'invalid',
319
428
  args: {value: 123, dataQuality: ReadoutBlockDataQuality.invalid},
320
429
  },
321
- {title: 'null (dash)', args: {value: undefined}},
430
+ {title: 'null (dash)', args: {value: null}},
322
431
  ]),
323
432
  };
324
433
 
@@ -14,6 +14,10 @@ import '../../icons/icon-notification-advice.js';
14
14
  import {
15
15
  formatNumericValue,
16
16
  readoutFormattedInteger,
17
+ assertReadoutValueType,
18
+ resolveReadoutNumericValue,
19
+ resolveReadoutTextValue,
20
+ ReadoutValueType,
17
21
  type ReadoutNumericFormatOptions,
18
22
  } from '../../navigation-instruments/readout/readout-formatters.js';
19
23
  import {
@@ -27,6 +31,7 @@ export {
27
31
  ObcTextboxFontWeight,
28
32
  ObcTextboxAlignment,
29
33
  } from '../../components/textbox/textbox.js';
34
+ export {ReadoutValueType} from '../../navigation-instruments/readout/readout-formatters.js';
30
35
 
31
36
  /**
32
37
  * Semantic variant of the block. Drives the default marker icon and the
@@ -83,6 +88,9 @@ export enum ReadoutBlockHidePhase {
83
88
  * role default), an optional trailing degree glyph, an `off`/unavailable text
84
89
  * state, per-block data-quality and an optional per-block alert frame.
85
90
  *
91
+ * Setting `valueType` to `text` renders `value` verbatim (e.g.
92
+ * `"Auto"`) instead of a formatted number.
93
+ *
86
94
  * This is a building block used inside `obc-readout-list-item` (and, in a future
87
95
  * refactor, inside `obc-readout`); it is not normally used on its own. Colour is
88
96
  * inherited from the host context (the parent sets the role colour), so the
@@ -105,8 +113,19 @@ export class ObcReadoutBlock extends LitElement {
105
113
  @property({type: String}) variant: ReadoutBlockVariant =
106
114
  ReadoutBlockVariant.value;
107
115
 
108
- /** The numeric value; `null`/`undefined` renders a dash. */
109
- @property({type: Number}) value: number | null = null;
116
+ /**
117
+ * The value; `null`/`undefined` renders a dash. A number by default, or text
118
+ * when {@link valueType} is `text`.
119
+ */
120
+ @property({type: String}) value: number | string | null = null;
121
+
122
+ /**
123
+ * How {@link value} is interpreted. `number` (default) formats it via
124
+ * `fractionDigits`; `text` renders it verbatim and ignores the numeric
125
+ * format options. Passing text while this is `number` throws.
126
+ */
127
+ @property({type: String}) valueType: ReadoutValueType =
128
+ ReadoutValueType.number;
110
129
 
111
130
  /** Density tier — icon size, gap, degree tier. */
112
131
  @property({type: String}) size: ReadoutBlockSize = ReadoutBlockSize.small;
@@ -131,13 +150,22 @@ export class ObcReadoutBlock extends LitElement {
131
150
  /** Show the leading marker-icon container (always on for setpoint/advice). */
132
151
  @property({type: Boolean}) hasIcon = false;
133
152
 
134
- /** Number of fraction digits. */
153
+ /**
154
+ * Number of fraction digits.
155
+ * @availableWhen valueType==number
156
+ */
135
157
  @property({type: Number}) fractionDigits = 0;
136
158
 
137
- /** Integer digits to reserve / hint (independent of `fractionDigits`). */
159
+ /**
160
+ * Integer digits to reserve / hint (independent of `fractionDigits`).
161
+ * @availableWhen valueType==number
162
+ */
138
163
  @property({type: Number}) maxDigits = 0;
139
164
 
140
- /** Render muted leading zeros filling the integer part to `maxDigits`. */
165
+ /**
166
+ * Render muted leading zeros filling the integer part to `maxDigits`.
167
+ * @availableWhen valueType==number
168
+ */
141
169
  @property({type: Boolean}) hintedZeros = false;
142
170
 
143
171
  /** Explicit longest string to reserve width for (e.g. `"0000.0"`). */
@@ -280,19 +308,41 @@ export class ObcReadoutBlock extends LitElement {
280
308
  `;
281
309
  }
282
310
 
311
+ protected override willUpdate(changed: Map<string, unknown>): void {
312
+ super.willUpdate(changed);
313
+ // Validated on EVERY update, deliberately NOT gated on `value`/`valueType`
314
+ // appearing in `changed`. When this assertion throws, Lit's `performUpdate`
315
+ // catch calls `__markUpdated()`, which clears the changed-properties map. A
316
+ // later update driven by any OTHER property — inside `obc-readout-list`,
317
+ // `align()` writing the shared reservers — would then see no `value` in
318
+ // `changed`, skip the check, and render the invalid value as a plain dash:
319
+ // exactly the silent failure this assertion exists to prevent.
320
+ assertReadoutValueType('obc-readout-block', this.value, this.valueType);
321
+ }
322
+
283
323
  override render() {
284
324
  const valueSize = this.resolvedValueSize;
285
325
  const formatOptions = this.numericFormatOptions;
286
- const valueForFormat = this.value ?? undefined;
326
+ const isTextMode = this.valueType === ReadoutValueType.text;
327
+ const valueForFormat = resolveReadoutNumericValue(
328
+ this.value,
329
+ this.valueType
330
+ );
331
+ const textValue = resolveReadoutTextValue(this.value, this.valueType);
332
+ // Text mode renders verbatim and ignores the numeric format options; a
333
+ // blank / missing text value still falls back to the unavailable dash.
287
334
  const text = this.off
288
335
  ? this.offText
289
- : formatNumericValue(valueForFormat, formatOptions);
336
+ : isTextMode
337
+ ? (textValue ?? '-')
338
+ : formatNumericValue(valueForFormat, formatOptions);
290
339
  // Hinted zeros pad the INTEGER part up to `maxDigits`, independent of
291
340
  // `fractionDigits` (the decimal point and fraction digits never count toward
292
341
  // `maxDigits`). Negative / dashed values are not padded. Example: value 1.2,
293
342
  // maxDigits 3, fractionDigits 1 → "001.2".
294
343
  const hintCount =
295
344
  this.off ||
345
+ isTextMode ||
296
346
  !this.hintedZeros ||
297
347
  valueForFormat === undefined ||
298
348
  valueForFormat < 0
@@ -303,9 +353,13 @@ export class ObcReadoutBlock extends LitElement {
303
353
  // `hintedZeros` is enabled an explicit `spaceReserver` is ignored (it has
304
354
  // higher priority). Otherwise the wider of the explicit reserver and the
305
355
  // `maxDigits`-derived reserve wins.
306
- const reserver = this.hintedZeros
307
- ? this.reserverText
308
- : this.widerReserver(this.spaceReserver, this.reserverText);
356
+ // Text mode ignores the `maxDigits`-derived numeric reserve — only an
357
+ // explicit `spaceReserver` still applies.
358
+ const reserver = isTextMode
359
+ ? (this.spaceReserver ?? '')
360
+ : this.hintedZeros
361
+ ? this.reserverText
362
+ : this.widerReserver(this.spaceReserver, this.reserverText);
309
363
 
310
364
  const block = html`
311
365
  <div
@@ -0,0 +1,229 @@
1
+ import {describe, it, expect} from 'vitest';
2
+ import {
3
+ assertReadoutValueType,
4
+ resolveReadoutNumericValue,
5
+ resolveReadoutTextValue,
6
+ isReadoutValueType,
7
+ ReadoutValueType,
8
+ } from './readout-formatters.js';
9
+
10
+ describe('assertReadoutValueType', () => {
11
+ it('accepts a number when valueType is number', () => {
12
+ expect(() =>
13
+ assertReadoutValueType('obc-readout', 12.4, ReadoutValueType.number)
14
+ ).not.toThrow();
15
+ });
16
+
17
+ it('accepts null when valueType is number', () => {
18
+ expect(() =>
19
+ assertReadoutValueType('obc-readout', null, ReadoutValueType.number)
20
+ ).not.toThrow();
21
+ });
22
+
23
+ // HTML attributes are always strings, so `<obc-readout value="12.4">` must
24
+ // keep working under the default valueType.
25
+ it('accepts a numeric-looking string when valueType is number', () => {
26
+ expect(() =>
27
+ assertReadoutValueType('obc-readout', '12.4', ReadoutValueType.number)
28
+ ).not.toThrow();
29
+ });
30
+
31
+ it('accepts a negative numeric string when valueType is number', () => {
32
+ expect(() =>
33
+ assertReadoutValueType('obc-readout', '-12.4', ReadoutValueType.number)
34
+ ).not.toThrow();
35
+ });
36
+
37
+ // `value="${maybeUndefined}"` is a common template shape, and Number('') is
38
+ // 0 — a silently wrong reading, which is worse than a dash.
39
+ it('accepts a blank string when valueType is number', () => {
40
+ expect(() =>
41
+ assertReadoutValueType('obc-readout', '', ReadoutValueType.number)
42
+ ).not.toThrow();
43
+ expect(() =>
44
+ assertReadoutValueType('obc-readout', ' ', ReadoutValueType.number)
45
+ ).not.toThrow();
46
+ });
47
+
48
+ it('throws for text when valueType is number', () => {
49
+ expect(() =>
50
+ assertReadoutValueType('obc-readout', 'Auto', ReadoutValueType.number)
51
+ ).toThrow(TypeError);
52
+ });
53
+
54
+ it('names the offending tag and value in the message', () => {
55
+ expect(() =>
56
+ assertReadoutValueType(
57
+ 'obc-readout-list-item',
58
+ 'Thermo On',
59
+ ReadoutValueType.number
60
+ )
61
+ ).toThrow(/obc-readout-list-item.*Thermo On.*valueType="text"/s);
62
+ });
63
+
64
+ it('throws for a partially numeric string when valueType is number', () => {
65
+ expect(() =>
66
+ assertReadoutValueType('obc-readout', '12.4 kn', ReadoutValueType.number)
67
+ ).toThrow(TypeError);
68
+ });
69
+
70
+ it('never throws when valueType is text', () => {
71
+ expect(() =>
72
+ assertReadoutValueType('obc-readout', 'Auto', ReadoutValueType.text)
73
+ ).not.toThrow();
74
+ expect(() =>
75
+ assertReadoutValueType('obc-readout', 12.4, ReadoutValueType.text)
76
+ ).not.toThrow();
77
+ });
78
+
79
+ // An attribute carries an unchecked string, so a typo matches neither mode.
80
+ // Without this it falls through every check and silently renders a dash.
81
+ it('throws for an unrecognised valueType', () => {
82
+ expect(() =>
83
+ assertReadoutValueType(
84
+ 'obc-readout',
85
+ 'Auto',
86
+ 'strng' as unknown as ReadoutValueType
87
+ )
88
+ ).toThrow(/valueType must be "number" or "text".*"strng"/s);
89
+ });
90
+
91
+ it('throws for an unrecognised valueType even with a numeric value', () => {
92
+ expect(() =>
93
+ assertReadoutValueType(
94
+ 'obc-readout',
95
+ 12.4,
96
+ 'STRING' as unknown as ReadoutValueType
97
+ )
98
+ ).toThrow(TypeError);
99
+ });
100
+
101
+ // undefined/null mean "use the default", so they must still enforce the
102
+ // number contract rather than skipping validation entirely.
103
+ it('treats an unset valueType as number', () => {
104
+ expect(() =>
105
+ assertReadoutValueType(
106
+ 'obc-readout',
107
+ 'Auto',
108
+ undefined as unknown as ReadoutValueType
109
+ )
110
+ ).toThrow(TypeError);
111
+ expect(() =>
112
+ assertReadoutValueType(
113
+ 'obc-readout',
114
+ 12.4,
115
+ undefined as unknown as ReadoutValueType
116
+ )
117
+ ).not.toThrow();
118
+ });
119
+ });
120
+
121
+ describe('isReadoutValueType', () => {
122
+ it('accepts the supported values', () => {
123
+ expect(isReadoutValueType('number')).toBe(true);
124
+ expect(isReadoutValueType('text')).toBe(true);
125
+ });
126
+
127
+ it('rejects anything else', () => {
128
+ // `string` names the JS type rather than the mode, and is the likely
129
+ // mistake for anyone reaching for the TypeScript word.
130
+ expect(isReadoutValueType('string')).toBe(false);
131
+ expect(isReadoutValueType('strng')).toBe(false);
132
+ expect(isReadoutValueType('Number')).toBe(false);
133
+ expect(isReadoutValueType('')).toBe(false);
134
+ expect(isReadoutValueType(undefined)).toBe(false);
135
+ expect(isReadoutValueType(null)).toBe(false);
136
+ expect(isReadoutValueType(0)).toBe(false);
137
+ });
138
+ });
139
+
140
+ describe('resolveReadoutNumericValue', () => {
141
+ it('passes a number through unchanged', () => {
142
+ expect(resolveReadoutNumericValue(12.4, ReadoutValueType.number)).toBe(
143
+ 12.4
144
+ );
145
+ });
146
+
147
+ it('parses a numeric string', () => {
148
+ expect(resolveReadoutNumericValue('12.4', ReadoutValueType.number)).toBe(
149
+ 12.4
150
+ );
151
+ });
152
+
153
+ it('is undefined for null, undefined and blank strings', () => {
154
+ expect(
155
+ resolveReadoutNumericValue(null, ReadoutValueType.number)
156
+ ).toBeUndefined();
157
+ expect(
158
+ resolveReadoutNumericValue(undefined, ReadoutValueType.number)
159
+ ).toBeUndefined();
160
+ expect(
161
+ resolveReadoutNumericValue('', ReadoutValueType.number)
162
+ ).toBeUndefined();
163
+ expect(
164
+ resolveReadoutNumericValue(' ', ReadoutValueType.number)
165
+ ).toBeUndefined();
166
+ });
167
+
168
+ it('is undefined for non-numeric text', () => {
169
+ expect(
170
+ resolveReadoutNumericValue('Auto', ReadoutValueType.number)
171
+ ).toBeUndefined();
172
+ });
173
+
174
+ // Text rows must contribute nothing to obc-readout-list's shared reserver.
175
+ it('is undefined in text mode even for a numeric value', () => {
176
+ expect(
177
+ resolveReadoutNumericValue(12.4, ReadoutValueType.text)
178
+ ).toBeUndefined();
179
+ expect(
180
+ resolveReadoutNumericValue('12.4', ReadoutValueType.text)
181
+ ).toBeUndefined();
182
+ });
183
+
184
+ // Pre-existing behaviour, deliberately preserved: NaN reaches the formatter.
185
+ it('passes NaN through in number mode', () => {
186
+ expect(
187
+ resolveReadoutNumericValue(Number.NaN, ReadoutValueType.number)
188
+ ).toBeNaN();
189
+ });
190
+ });
191
+
192
+ describe('resolveReadoutTextValue', () => {
193
+ it('returns the text verbatim in text mode', () => {
194
+ expect(resolveReadoutTextValue('Thermo On', ReadoutValueType.text)).toBe(
195
+ 'Thermo On'
196
+ );
197
+ });
198
+
199
+ // The reason a converter was rejected: it would have parsed this to 1.5.
200
+ it('preserves trailing zeros in text mode', () => {
201
+ expect(resolveReadoutTextValue('1.50', ReadoutValueType.text)).toBe('1.50');
202
+ });
203
+
204
+ it('coerces a number to text in text mode', () => {
205
+ expect(resolveReadoutTextValue(12.4, ReadoutValueType.text)).toBe('12.4');
206
+ });
207
+
208
+ it('is undefined for null, undefined and blank strings', () => {
209
+ expect(
210
+ resolveReadoutTextValue(null, ReadoutValueType.text)
211
+ ).toBeUndefined();
212
+ expect(
213
+ resolveReadoutTextValue(undefined, ReadoutValueType.text)
214
+ ).toBeUndefined();
215
+ expect(resolveReadoutTextValue('', ReadoutValueType.text)).toBeUndefined();
216
+ expect(
217
+ resolveReadoutTextValue(' ', ReadoutValueType.text)
218
+ ).toBeUndefined();
219
+ });
220
+
221
+ it('is undefined in number mode', () => {
222
+ expect(
223
+ resolveReadoutTextValue('12.4', ReadoutValueType.number)
224
+ ).toBeUndefined();
225
+ expect(
226
+ resolveReadoutTextValue(12.4, ReadoutValueType.number)
227
+ ).toBeUndefined();
228
+ });
229
+ });
@@ -4,6 +4,111 @@ export type ReadoutNumericFormatOptions = {
4
4
  fractionDigits: number;
5
5
  };
6
6
 
7
+ /**
8
+ * How a readout's `value` is interpreted.
9
+ * - `number`: formatted via `fractionDigits` / `maxDigits`.
10
+ * - `text`: rendered verbatim, with the numeric format options ignored.
11
+ */
12
+ export enum ReadoutValueType {
13
+ number = 'number',
14
+ text = 'text',
15
+ }
16
+
17
+ const READOUT_VALUE_TYPES: readonly string[] = Object.values(ReadoutValueType);
18
+
19
+ /** Whether `value` is one of the supported {@link ReadoutValueType} values. */
20
+ export function isReadoutValueType(value: unknown): value is ReadoutValueType {
21
+ return typeof value === 'string' && READOUT_VALUE_TYPES.includes(value);
22
+ }
23
+
24
+ function isBlank(value: string): boolean {
25
+ return value.trim() === '';
26
+ }
27
+
28
+ /**
29
+ * Throws when `valueType` is not a supported value, or when `value` is text but
30
+ * `valueType` is `number`.
31
+ *
32
+ * `valueType` is validated first because an attribute carries an unchecked
33
+ * string: a typo such as `valuetype="strng"` matches neither mode, so every
34
+ * mode check falls through and the readout silently renders the unavailable
35
+ * dash — the opposite of the loud failure this contract exists to give.
36
+ * `undefined`/`null` are allowed and mean "use the default".
37
+ *
38
+ * Attributes are always strings, so a numeric-looking string (`value="10.12"`)
39
+ * is accepted and parsed. Blank strings resolve to the unavailable dash rather
40
+ * than throwing — `value="${maybeUndefined}"` is a common template shape, and
41
+ * `Number('')` is `0`, a silently wrong reading.
42
+ */
43
+ export function assertReadoutValueType(
44
+ tagName: string,
45
+ value: number | string | null | undefined,
46
+ valueType: ReadoutValueType
47
+ ): void {
48
+ // `undefined`/`null` mean "use the default", matching how the components and
49
+ // the resolvers treat an unset `valueType`.
50
+ const resolved = valueType ?? ReadoutValueType.number;
51
+ if (!isReadoutValueType(resolved)) {
52
+ throw new TypeError(
53
+ `<${tagName}>: valueType must be ` +
54
+ `${READOUT_VALUE_TYPES.map((t) => `"${t}"`).join(' or ')} ` +
55
+ `(got ${JSON.stringify(valueType)}).`
56
+ );
57
+ }
58
+ if (
59
+ resolved !== ReadoutValueType.number ||
60
+ typeof value !== 'string' ||
61
+ isBlank(value)
62
+ ) {
63
+ return;
64
+ }
65
+ if (Number.isFinite(Number(value))) {
66
+ return;
67
+ }
68
+ throw new TypeError(
69
+ `<${tagName}>: value must be a number when valueType is "number" ` +
70
+ `(got ${JSON.stringify(value)}). Set valueType="text" to render text.`
71
+ );
72
+ }
73
+
74
+ /** The value as a number, or `undefined` when it is text / unavailable. */
75
+ export function resolveReadoutNumericValue(
76
+ value: number | string | null | undefined,
77
+ valueType: ReadoutValueType
78
+ ): number | undefined {
79
+ if (
80
+ valueType === ReadoutValueType.text ||
81
+ value === null ||
82
+ value === undefined
83
+ ) {
84
+ return undefined;
85
+ }
86
+ if (typeof value === 'number') {
87
+ return value;
88
+ }
89
+ if (isBlank(value)) {
90
+ return undefined;
91
+ }
92
+ const parsed = Number(value);
93
+ return Number.isFinite(parsed) ? parsed : undefined;
94
+ }
95
+
96
+ /** The value as display text, or `undefined` when not in text mode / blank. */
97
+ export function resolveReadoutTextValue(
98
+ value: number | string | null | undefined,
99
+ valueType: ReadoutValueType
100
+ ): string | undefined {
101
+ if (
102
+ valueType !== ReadoutValueType.text ||
103
+ value === null ||
104
+ value === undefined
105
+ ) {
106
+ return undefined;
107
+ }
108
+ const text = typeof value === 'number' ? String(value) : value;
109
+ return isBlank(text) ? undefined : text;
110
+ }
111
+
7
112
  function dashedGenerator({
8
113
  showZeroPadding,
9
114
  minValueLength,