@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.
- package/bundle/openbridge-webcomponents.bundle.js +112 -13
- package/bundle/openbridge-webcomponents.bundle.js.map +1 -1
- package/custom-elements.json +101 -14
- package/dist/building-blocks/readout-block/readout-block.d.ts +29 -5
- package/dist/building-blocks/readout-block/readout-block.d.ts.map +1 -1
- package/dist/building-blocks/readout-block/readout-block.js +21 -7
- package/dist/building-blocks/readout-block/readout-block.js.map +1 -1
- package/dist/navigation-instruments/readout/readout-formatters.d.ts +31 -0
- package/dist/navigation-instruments/readout/readout-formatters.d.ts.map +1 -1
- package/dist/navigation-instruments/readout/readout-formatters.js +55 -1
- package/dist/navigation-instruments/readout/readout-formatters.js.map +1 -1
- package/dist/navigation-instruments/readout/readout.d.ts +26 -1
- package/dist/navigation-instruments/readout/readout.d.ts.map +1 -1
- package/dist/navigation-instruments/readout/readout.js +16 -3
- package/dist/navigation-instruments/readout/readout.js.map +1 -1
- package/dist/navigation-instruments/readout-list/readout-list.d.ts +6 -1
- package/dist/navigation-instruments/readout-list/readout-list.d.ts.map +1 -1
- package/dist/navigation-instruments/readout-list/readout-list.js +21 -4
- package/dist/navigation-instruments/readout-list/readout-list.js.map +1 -1
- package/dist/navigation-instruments/readout-list-item/readout-list-item.d.ts +26 -1
- package/dist/navigation-instruments/readout-list-item/readout-list-item.d.ts.map +1 -1
- package/dist/navigation-instruments/readout-list-item/readout-list-item.js +15 -3
- package/dist/navigation-instruments/readout-list-item/readout-list-item.js.map +1 -1
- package/package.json +1 -1
- package/src/building-blocks/readout-block/readout-block.stories.ts +112 -3
- package/src/building-blocks/readout-block/readout-block.ts +64 -10
- package/src/navigation-instruments/readout/readout-formatters.spec.ts +229 -0
- package/src/navigation-instruments/readout/readout-formatters.ts +105 -0
- package/src/navigation-instruments/readout/readout.stories.ts +167 -3
- package/src/navigation-instruments/readout/readout.ts +52 -4
- package/src/navigation-instruments/readout-list/readout-list.stories.ts +122 -1
- package/src/navigation-instruments/readout-list/readout-list.ts +54 -5
- package/src/navigation-instruments/readout-list-item/readout-list-item.stories.ts +124 -4
- 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
|
-
|
|
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:
|
|
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
|
-
/**
|
|
109
|
-
|
|
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
|
-
/**
|
|
153
|
+
/**
|
|
154
|
+
* Number of fraction digits.
|
|
155
|
+
* @availableWhen valueType==number
|
|
156
|
+
*/
|
|
135
157
|
@property({type: Number}) fractionDigits = 0;
|
|
136
158
|
|
|
137
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
:
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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,
|