@adcops/autocore-react 3.3.115 → 3.3.116

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 (62) hide show
  1. package/dist/components/index.d.ts +10 -2
  2. package/dist/components/index.d.ts.map +1 -1
  3. package/dist/components/index.js +1 -1
  4. package/dist/components/tis/CycleConfigDialog.d.ts +39 -0
  5. package/dist/components/tis/CycleConfigDialog.d.ts.map +1 -0
  6. package/dist/components/tis/CycleConfigDialog.js +1 -0
  7. package/dist/components/tis/CycleConfigForm.d.ts +22 -0
  8. package/dist/components/tis/CycleConfigForm.d.ts.map +1 -0
  9. package/dist/components/tis/CycleConfigForm.js +1 -0
  10. package/dist/components/tis/CycleStatusStrip.d.ts +10 -0
  11. package/dist/components/tis/CycleStatusStrip.d.ts.map +1 -0
  12. package/dist/components/tis/CycleStatusStrip.js +1 -0
  13. package/dist/components/tis/TestDataView.d.ts +4 -0
  14. package/dist/components/tis/TestDataView.d.ts.map +1 -1
  15. package/dist/components/tis/TestDataView.js +1 -1
  16. package/dist/components/tis/TestFieldRow.d.ts +151 -0
  17. package/dist/components/tis/TestFieldRow.d.ts.map +1 -0
  18. package/dist/components/tis/TestFieldRow.js +1 -0
  19. package/dist/components/tis/TestSetupForm.d.ts +15 -51
  20. package/dist/components/tis/TestSetupForm.d.ts.map +1 -1
  21. package/dist/components/tis/TestSetupForm.js +1 -1
  22. package/dist/components/tis/TestSetupView.d.ts.map +1 -1
  23. package/dist/components/tis/TestSetupView.js +1 -1
  24. package/dist/components/tis/TisProvider.d.ts +80 -0
  25. package/dist/components/tis/TisProvider.d.ts.map +1 -1
  26. package/dist/components/tis/TisProvider.js +1 -1
  27. package/dist/components/tis-editor/TisConfigEditor.d.ts.map +1 -1
  28. package/dist/components/tis-editor/TisConfigEditor.js +1 -1
  29. package/dist/components/tis-editor/editor/CycleConfigSection.d.ts +19 -0
  30. package/dist/components/tis-editor/editor/CycleConfigSection.d.ts.map +1 -0
  31. package/dist/components/tis-editor/editor/CycleConfigSection.js +1 -0
  32. package/dist/components/tis-editor/editor/FieldArrayEditor.d.ts.map +1 -1
  33. package/dist/components/tis-editor/editor/FieldArrayEditor.js +1 -1
  34. package/dist/components/tis-editor/editor/MethodFormEditor.d.ts.map +1 -1
  35. package/dist/components/tis-editor/editor/MethodFormEditor.js +1 -1
  36. package/dist/components/tis-editor/editor/TestFieldDialog.d.ts +3 -0
  37. package/dist/components/tis-editor/editor/TestFieldDialog.d.ts.map +1 -1
  38. package/dist/components/tis-editor/editor/TestFieldDialog.js +1 -1
  39. package/dist/components/tis-editor/editor/ViewsEditor.d.ts.map +1 -1
  40. package/dist/components/tis-editor/editor/ViewsEditor.js +1 -1
  41. package/dist/components/tis-editor/types.d.ts +28 -1
  42. package/dist/components/tis-editor/types.d.ts.map +1 -1
  43. package/dist/components/tis-editor/validation.d.ts.map +1 -1
  44. package/dist/components/tis-editor/validation.js +1 -1
  45. package/package.json +1 -1
  46. package/src/components/index.ts +14 -0
  47. package/src/components/tis/CycleConfigDialog.tsx +173 -0
  48. package/src/components/tis/CycleConfigForm.tsx +192 -0
  49. package/src/components/tis/CycleStatusStrip.tsx +84 -0
  50. package/src/components/tis/TestDataView.tsx +19 -3
  51. package/src/components/tis/TestFieldRow.tsx +297 -0
  52. package/src/components/tis/TestSetupForm.tsx +52 -212
  53. package/src/components/tis/TestSetupView.tsx +60 -3
  54. package/src/components/tis/TisProvider.tsx +191 -0
  55. package/src/components/tis-editor/TisConfigEditor.tsx +1 -0
  56. package/src/components/tis-editor/editor/CycleConfigSection.tsx +93 -0
  57. package/src/components/tis-editor/editor/FieldArrayEditor.tsx +10 -0
  58. package/src/components/tis-editor/editor/MethodFormEditor.tsx +10 -4
  59. package/src/components/tis-editor/editor/TestFieldDialog.tsx +22 -2
  60. package/src/components/tis-editor/editor/ViewsEditor.tsx +3 -0
  61. package/src/components/tis-editor/types.ts +34 -1
  62. package/src/components/tis-editor/validation.ts +20 -2
@@ -0,0 +1,297 @@
1
+ /*
2
+ * Copyright (C) 2026 Automated Design Corp. All Rights Reserved.
3
+ *
4
+ * The one place a TIS `TestField` becomes an input.
5
+ *
6
+ * Test Configuration (<TestSetupForm>, per-test) and Cycle Configuration
7
+ * (<CycleConfigForm>, per-row) render the same declarative field schema
8
+ * with the same display/raw unit convention, the same emptiness and range
9
+ * rules, and the same 4-column `ac-form-grid` row shape. They differ only
10
+ * in where the value is stored and when it is committed, so the rendering
11
+ * and the validation rules live here and both forms call in.
12
+ *
13
+ * Storage is always RAW; `scale` converts only at the boundary:
14
+ * display = raw * scale raw = display / scale
15
+ */
16
+
17
+ import React from 'react';
18
+ import { Button } from 'primereact/button';
19
+ import { Dropdown } from 'primereact/dropdown';
20
+ import { ValueInput } from '../ValueInput';
21
+ import { TextInput } from '../TextInput';
22
+
23
+ /**
24
+ * One field in a test method's schema. Mirrors the Rust `TestField`
25
+ * (autocore-ams `config.rs`) — the same shape backs project_fields,
26
+ * config_fields, cycle_config_fields, cycle_fields and results_fields.
27
+ */
28
+ export interface TestFieldDef {
29
+ /** Canonical key — wire format, generated code, on-disk JSON. */
30
+ name: string;
31
+ type: string;
32
+ units?: string;
33
+ required?: boolean;
34
+ source?: string;
35
+ /** Pretty label rendered by the form. Falls back to `name`. Units
36
+ * are appended automatically; don't pre-format `[mm]` into label. */
37
+ label?: string;
38
+ /** Long-form guidance surfaced as a hover tooltip on an info icon. */
39
+ description?: string;
40
+ /** Seed value applied when the operator selects this test method.
41
+ * For source-bound fields the default is written to the GM tag so
42
+ * the control program sees it; for non-source fields it's stashed
43
+ * straight into stagedConfig. Operator edits override per-stage,
44
+ * but the schema default never mutates — re-selecting the method
45
+ * re-applies the default. **Authored in display units** when
46
+ * `scale` is set — the form divides by `scale` before writing to
47
+ * GM / stagedConfig so the underlying storage stays raw. */
48
+ default?: any;
49
+ /** Optional unit-conversion multiplier. Mirrors AutoCoreTagContext:
50
+ * ```
51
+ * display = raw * scale
52
+ * raw = display / scale
53
+ * ```
54
+ * Example: backend stores degrees, operator enters revolutions →
55
+ * `scale: 0.00277778`. None / 1.0 = no conversion.
56
+ *
57
+ * Storage stays raw — the form scales only on input and display.
58
+ * Cycle and results values are scaled by the corresponding paths
59
+ * in TestDataView; the server scales CSV exports too. */
60
+ scale?: number;
61
+ /** Optional inclusive bounds (display units, same convention as `default`
62
+ * and `scale`) for the operator's numeric entry. The numeric input
63
+ * rejects values outside `[min, max]`; non-numeric fields ignore them. */
64
+ min?: number;
65
+ max?: number;
66
+ /** Optional fixed set of choices. When present, the field renders
67
+ * as a dropdown and the operator must pick one of the declared
68
+ * values rather than typing freely. Each entry is either a bare
69
+ * scalar (label === value) or an explicit `{ label, value }` pair
70
+ * when the displayed text should differ from the stored value.
71
+ * Works with any `type`; like `default`, values are authored in
72
+ * display units when `scale` is set. */
73
+ options?: Array<
74
+ | string
75
+ | number
76
+ | boolean
77
+ | { label?: string; value: string | number | boolean }
78
+ >;
79
+ /** Operator-editable after recording. Meaningful on `cycle_fields`;
80
+ * `cycle_config_fields` are always editable (they're annotation, not
81
+ * measurement) and don't need the flag. */
82
+ editable?: boolean;
83
+ /** `cycle_config_fields` only: clear this entry once a row has recorded
84
+ * it, so the next row can't silently inherit a stale annotation. Fires
85
+ * at the method's `cycle_config_scope` boundary. */
86
+ autoclear?: boolean;
87
+ }
88
+
89
+ /**
90
+ * Which boundary an operator annotates `cycle_config_fields` at. Mirrors
91
+ * the Rust `CycleConfigScope`.
92
+ *
93
+ * `'run'` one latch per Start — the same values stamp every cycle of
94
+ * the run and clear when it closes. Default, and the only
95
+ * correct choice for a machine that records many cycles from
96
+ * one Start.
97
+ * `'cycle'` one latch per cycle — values clear after each recorded row
98
+ * and the gate re-arms, so the operator annotates every one.
99
+ */
100
+ export type CycleConfigScope = 'run' | 'cycle';
101
+
102
+ // -------------------------------------------------------------------------
103
+ // Display ↔ raw boundary
104
+ // -------------------------------------------------------------------------
105
+
106
+ /**
107
+ * Convention: `display = raw * scale`, `raw = display / scale`. Matches
108
+ * AutoCoreTagContext. Default scale = 1.0 (no conversion). Non-numeric
109
+ * values (string, null, undefined, NaN) pass through unchanged — the
110
+ * forms share one change handler across fields of every type.
111
+ *
112
+ * Storage (config map, GM, on-disk JSON) always holds the raw value. Only
113
+ * the inputs the operator looks at and the cells in TestDataView use the
114
+ * display value, so scales can change in project.json without
115
+ * invalidating historical records.
116
+ */
117
+ export const rawToDisplay = (raw: any, scale: number | undefined): any => {
118
+ if (!scale || scale === 1) return raw;
119
+ if (typeof raw !== 'number' || !Number.isFinite(raw)) return raw;
120
+ return raw * scale;
121
+ };
122
+
123
+ export const displayToRaw = (display: any, scale: number | undefined): any => {
124
+ if (!scale || scale === 1) return display;
125
+ if (typeof display !== 'number' || !Number.isFinite(display)) return display;
126
+ return display / scale;
127
+ };
128
+
129
+ /** Field label with `[units]` appended when declared. */
130
+ export const labelOf = (f: TestFieldDef): string => {
131
+ const base = f.label && f.label.length > 0 ? f.label : f.name;
132
+ return f.units ? `${base} [${f.units}]` : base;
133
+ };
134
+
135
+ export const hasDescription = (f: TestFieldDef): boolean =>
136
+ typeof f.description === 'string' && f.description.length > 0;
137
+
138
+ /**
139
+ * "Not entered." Mirrors the server's `cycle_config_value_is_empty`
140
+ * (autocore-tis `config.rs`) so the HMI gate and the GM gate can never
141
+ * disagree about whether a field is filled in: absent, null, or an
142
+ * empty/whitespace string. Numeric zero and `false` are entries.
143
+ */
144
+ export const fieldValueIsEmpty = (v: any): boolean =>
145
+ v === undefined || v === null || (typeof v === 'string' && v.trim() === '');
146
+
147
+ /**
148
+ * Range check for a numeric field's stored RAW value against its declared
149
+ * `min`/`max` (authored in DISPLAY units). Returns a short human-readable
150
+ * reason ("must be ≥ 5") when out of range, or null when in range / not
151
+ * applicable (empty value, no bounds, or non-numeric).
152
+ */
153
+ export const rangeIssue = (f: TestFieldDef, raw: any): string | null => {
154
+ if (fieldValueIsEmpty(raw) || raw === '') return null;
155
+ if (f.min == null && f.max == null) return null;
156
+ const disp = Number(rawToDisplay(Number(raw), f.scale));
157
+ if (!Number.isFinite(disp)) return null;
158
+ // Bounds form an unordered interval — an author may write the range in
159
+ // magnitude order (min:-50, max:-1000), which is numerically inverted.
160
+ const lo = f.min != null && f.max != null ? Math.min(f.min, f.max) : f.min;
161
+ const hi = f.min != null && f.max != null ? Math.max(f.min, f.max) : f.max;
162
+ if (lo != null && disp < lo) return `must be ≥ ${lo}`;
163
+ if (hi != null && disp > hi) return `must be ≤ ${hi}`;
164
+ return null;
165
+ };
166
+
167
+ /**
168
+ * The single validity rule for an operator-entered field: required-and-empty,
169
+ * or out of range. Returns a human-readable reason, or null when the value
170
+ * is acceptable. Both forms build their issue lists and their per-field red
171
+ * markers from this, so the summary dialog and the field icons always agree.
172
+ */
173
+ export const fieldIssue = (f: TestFieldDef, raw: any): string | null => {
174
+ if (f.required && fieldValueIsEmpty(raw)) return 'is required';
175
+ return rangeIssue(f, raw);
176
+ };
177
+
178
+ export const fieldIsValid = (f: TestFieldDef, raw: any): boolean =>
179
+ fieldIssue(f, raw) === null;
180
+
181
+ /**
182
+ * Normalise a field's `options` (bare scalars and/or `{label, value}`
183
+ * pairs) into the `{ label, value }[]` shape PrimeReact's Dropdown wants.
184
+ * Bare scalars use their stringified form as the label.
185
+ */
186
+ export const normalizeOptions = (
187
+ opts: TestFieldDef['options'],
188
+ ): { label: string; value: string | number | boolean }[] =>
189
+ (opts ?? []).map((o) =>
190
+ o !== null && typeof o === 'object'
191
+ ? { label: String(o.label ?? o.value), value: o.value }
192
+ : { label: String(o), value: o },
193
+ );
194
+
195
+ // -------------------------------------------------------------------------
196
+ // Rendering
197
+ // -------------------------------------------------------------------------
198
+
199
+ export interface TestFieldRowProps {
200
+ field: TestFieldDef;
201
+ /** Stored RAW value. */
202
+ value: any;
203
+ /** Receives the RAW value (the row converts from display for you). */
204
+ onChange: (raw: any) => void;
205
+ /** Marks the control invalid. Defaults to `fieldIsValid(field, value)`. */
206
+ valid?: boolean;
207
+ /** Read-only rendering — used while a cycle is in flight and its
208
+ * annotation is already latched. */
209
+ disabled?: boolean;
210
+ /**
211
+ * Replaces the schema-derived input for this field. Used by
212
+ * <TestSetupForm> for config fields an AMS `asset_ref` claims, where
213
+ * the picker is sourced from the asset registry rather than the field
214
+ * declaration.
215
+ */
216
+ control?: React.ReactNode;
217
+ /** Opens the host form's shared info dialog. When omitted, fields with
218
+ * a `description` render no info affordance. */
219
+ onInfo?: (field: TestFieldDef) => void;
220
+ }
221
+
222
+ /**
223
+ * One row of a 4-column `ac-form-grid`: label, control, info button,
224
+ * validity icon. Returns a Fragment, so the caller owns the grid.
225
+ */
226
+ export const TestFieldRow: React.FC<TestFieldRowProps> = ({
227
+ field, value, onChange, valid, disabled, control, onInfo,
228
+ }) => {
229
+ const ok = valid ?? fieldIsValid(field, value);
230
+ const dropdownOptions = !control && Array.isArray(field.options) && field.options.length > 0
231
+ ? normalizeOptions(field.options)
232
+ : null;
233
+ const isNum = !control && !dropdownOptions
234
+ && field.type !== 'string' && field.type !== 'bool';
235
+
236
+ const input = control ?? (dropdownOptions ? (
237
+ <Dropdown
238
+ value={value ?? null}
239
+ options={dropdownOptions}
240
+ onChange={(e) => onChange(e.value)}
241
+ placeholder={`Select ${field.label ?? field.name}…`}
242
+ className={`ac-dropdown-clearable${!ok ? ' p-invalid' : ''}`}
243
+ showClear={!field.required}
244
+ disabled={disabled}
245
+ />
246
+ ) : isNum ? (
247
+ <ValueInput
248
+ label={undefined}
249
+ // Storage is RAW; render the DISPLAY value so the operator sees
250
+ // the units they configured. The change handler runs the inverse
251
+ // before committing back to storage.
252
+ value={value != null ? Number(rawToDisplay(Number(value), field.scale)) : null}
253
+ // min/max are authored in display units, matching the value
254
+ // rendered above — ValueInput rejects out-of-range entries.
255
+ min={field.min}
256
+ max={field.max}
257
+ onValueChanged={(val) => onChange(displayToRaw(val, field.scale))}
258
+ className={!ok ? 'p-invalid' : ''}
259
+ disabled={disabled}
260
+ />
261
+ ) : (
262
+ <TextInput
263
+ label={undefined}
264
+ value={value != null ? String(value) : ''}
265
+ onValueChanged={(val) => onChange(val)}
266
+ className={!ok ? 'p-invalid' : ''}
267
+ disabled={disabled}
268
+ />
269
+ ));
270
+
271
+ return (
272
+ <React.Fragment>
273
+ <span className="ac-form-label">{labelOf(field)}</span>
274
+ {input}
275
+ {onInfo && hasDescription(field) ? (
276
+ <Button
277
+ icon="pi pi-info-circle"
278
+ text
279
+ rounded
280
+ aria-label={`About ${labelOf(field)}`}
281
+ // Replaces a hover Tooltip — touch-friendly. The
282
+ // description is shown in the host form's info dialog.
283
+ onClick={() => onInfo(field)}
284
+ />
285
+ ) : (
286
+ <span aria-hidden="true" />
287
+ )}
288
+ <span style={{
289
+ color: ok ? 'var(--green-500)' : 'var(--red-500)',
290
+ display: 'flex',
291
+ alignItems: 'center',
292
+ }}>
293
+ <i className={ok ? 'pi pi-check' : 'pi pi-times'} />
294
+ </span>
295
+ </React.Fragment>
296
+ );
297
+ };
@@ -6,12 +6,26 @@ import { Dialog } from 'primereact/dialog';
6
6
  import { EventEmitterContext } from '../../core/EventEmitterContext';
7
7
  import { AutoCoreTagContext } from '../../core/AutoCoreTagContext';
8
8
  import { MessageType } from '../../hub/CommandMessage';
9
- import { ValueInput } from '../ValueInput';
10
9
  import { TextInput } from '../TextInput';
11
10
  import { useTis } from './TisProvider';
12
11
  import { useAmsAssets, useAmsRoles, type AmsAssetEntry } from '../ams/AmsProvider';
13
12
  import { TestMethodDialog } from './TestMethodDialog';
14
13
  import { ConfigurationDialog, configLabelOf } from './ConfigurationDialog';
14
+ import {
15
+ TestFieldRow,
16
+ displayToRaw,
17
+ fieldIsValid,
18
+ fieldIssue,
19
+ labelOf,
20
+ type CycleConfigScope,
21
+ type TestFieldDef,
22
+ } from './TestFieldRow';
23
+
24
+ // The field schema and its display/raw + validation helpers are shared with
25
+ // <CycleConfigForm> — see TestFieldRow.tsx. Re-exported here because this
26
+ // module has been the public home of `TestFieldDef` since before Cycle
27
+ // Configuration existed.
28
+ export type { TestFieldDef } from './TestFieldRow';
15
29
 
16
30
  /**
17
31
  * One asset_ref declared on a test method. We only consume the subset
@@ -27,64 +41,24 @@ interface TisAssetRef {
27
41
  location?: string;
28
42
  }
29
43
 
30
- export interface TestFieldDef {
31
- /** Canonical key — wire format, generated code, on-disk JSON. */
32
- name: string;
33
- type: string;
34
- units?: string;
35
- required?: boolean;
36
- source?: string;
37
- /** Pretty label rendered by the form. Falls back to `name`. Units
38
- * are appended automatically; don't pre-format `[mm]` into label. */
39
- label?: string;
40
- /** Long-form guidance surfaced as a hover tooltip on an info icon. */
41
- description?: string;
42
- /** Seed value applied when the operator selects this test method.
43
- * For source-bound fields the default is written to the GM tag so
44
- * the control program sees it; for non-source fields it's stashed
45
- * straight into stagedConfig. Operator edits override per-stage,
46
- * but the schema default never mutates — re-selecting the method
47
- * re-applies the default. **Authored in display units** when
48
- * `scale` is set — the form divides by `scale` before writing to
49
- * GM / stagedConfig so the underlying storage stays raw. */
50
- default?: any;
51
- /** Optional unit-conversion multiplier. Mirrors AutoCoreTagContext:
52
- * ```
53
- * display = raw * scale
54
- * raw = display / scale
55
- * ```
56
- * Example: backend stores degrees, operator enters revolutions →
57
- * `scale: 0.00277778`. None / 1.0 = no conversion.
58
- *
59
- * Storage stays raw — the form scales only on input and display.
60
- * Cycle and results values are scaled by the corresponding paths
61
- * in TestDataView; the server scales CSV exports too. */
62
- scale?: number;
63
- /** Optional inclusive bounds (display units, same convention as `default`
64
- * and `scale`) for the operator's numeric entry. The numeric input
65
- * rejects values outside `[min, max]`; non-numeric fields ignore them. */
66
- min?: number;
67
- max?: number;
68
- /** Optional fixed set of choices. When present, the field renders
69
- * as a dropdown and the operator must pick one of the declared
70
- * values rather than typing freely. Each entry is either a bare
71
- * scalar (label === value) or an explicit `{ label, value }` pair
72
- * when the displayed text should differ from the stored value.
73
- * Works with any `type`; like `default`, values are authored in
74
- * display units when `scale` is set. */
75
- options?: Array<
76
- | string
77
- | number
78
- | boolean
79
- | { label?: string; value: string | number | boolean }
80
- >;
81
- }
82
-
83
44
  export interface TestMethod {
84
45
  project_fields: TestFieldDef[];
85
46
  config_fields: TestFieldDef[];
86
47
  cycle_fields: TestFieldDef[];
87
48
  results_fields: TestFieldDef[];
49
+ /**
50
+ * Operator-entered per-row annotation, rendered on the Cycle tab by
51
+ * <CycleConfigForm> rather than by this form. Declared here so a
52
+ * generated schema literal typechecks against `TestMethod`.
53
+ */
54
+ cycle_config_fields?: TestFieldDef[];
55
+ /** Which boundary the operator annotates at: `'run'` (default — one
56
+ * latch per Start, stamping every cycle of the run) or `'cycle'`
57
+ * (one latch per cycle, re-gated between cycles). */
58
+ cycle_config_scope?: CycleConfigScope;
59
+ /** Open the Cycle Configuration dialog at the boundary instead of
60
+ * relying on the operator having filled the Cycle tab. */
61
+ prompt_before_cycle?: boolean;
88
62
  /**
89
63
  * AMS asset references resolved at start_test. The form scans these
90
64
  * for `select=by_id_field` entries pointing at a config field
@@ -159,38 +133,6 @@ export interface TestSetupFormProps {
159
133
  // Helpers
160
134
  // -------------------------------------------------------------------------
161
135
 
162
- const labelOf = (f: TestFieldDef): string => {
163
- const base = f.label && f.label.length > 0 ? f.label : f.name;
164
- return f.units ? `${base} [${f.units}]` : base;
165
- };
166
-
167
- /**
168
- * Two helpers for the display ↔ raw boundary on numeric fields.
169
- *
170
- * Convention: `display = raw * scale`, `raw = display / scale`. Matches
171
- * AutoCoreTagContext. Default scale = 1.0 (no conversion). Non-numeric
172
- * input values (string, null, undefined, NaN) pass through unchanged
173
- * — the form has fields of other types that share the change handler.
174
- *
175
- * Storage (config map, GM, on-disk JSON) always holds the raw value.
176
- * Only the inputs the operator looks at and the cells in TestDataView
177
- * use the display value. This way scales can change in project.json
178
- * without invalidating historical records.
179
- */
180
- const rawToDisplay = (raw: any, scale: number | undefined): any => {
181
- if (!scale || scale === 1) return raw;
182
- if (typeof raw !== 'number' || !Number.isFinite(raw)) return raw;
183
- return raw * scale;
184
- };
185
- const displayToRaw = (display: any, scale: number | undefined): any => {
186
- if (!scale || scale === 1) return display;
187
- if (typeof display !== 'number' || !Number.isFinite(display)) return display;
188
- return display / scale;
189
- };
190
-
191
- const hasDescription = (f: TestFieldDef): boolean =>
192
- typeof f.description === 'string' && f.description.length > 0;
193
-
194
136
  /** Matches an FQDN default token like `${gm.safe_speed}` (whole-string). */
195
137
  const DEFAULT_TOKEN_RE = /^\$\{\s*([^}]+?)\s*\}$/;
196
138
 
@@ -230,41 +172,6 @@ const resolveDefaultRaw = (
230
172
  return { raw: displayToRaw(d, field.scale), ok: true };
231
173
  };
232
174
 
233
- /**
234
- * Range check for a numeric field's stored RAW value against its declared
235
- * `min`/`max` (authored in DISPLAY units). Returns a short human-readable
236
- * reason ("must be ≥ 5") when out of range, or null when in range / not
237
- * applicable (empty value, no bounds, or non-numeric). Storage is raw, so we
238
- * convert to display first to compare against the author's display-unit bounds.
239
- */
240
- const rangeIssue = (f: TestFieldDef, raw: any): string | null => {
241
- if (raw === undefined || raw === '' || raw === null) return null;
242
- if (f.min == null && f.max == null) return null;
243
- const disp = Number(rawToDisplay(Number(raw), f.scale));
244
- if (!Number.isFinite(disp)) return null;
245
- // Bounds form an unordered interval — an author may write the range in
246
- // magnitude order (min:-50, max:-1000), which is numerically inverted.
247
- const lo = f.min != null && f.max != null ? Math.min(f.min, f.max) : f.min;
248
- const hi = f.min != null && f.max != null ? Math.max(f.min, f.max) : f.max;
249
- if (lo != null && disp < lo) return `must be ≥ ${lo}`;
250
- if (hi != null && disp > hi) return `must be ≤ ${hi}`;
251
- return null;
252
- };
253
-
254
- /**
255
- * Normalise a field's `options` (bare scalars and/or `{label, value}`
256
- * pairs) into the `{ label, value }[]` shape PrimeReact's Dropdown
257
- * wants. Bare scalars use their stringified form as the label.
258
- */
259
- const normalizeOptions = (
260
- opts: TestFieldDef['options'],
261
- ): { label: string; value: string | number | boolean }[] =>
262
- (opts ?? []).map((o) =>
263
- o !== null && typeof o === 'object'
264
- ? { label: String(o.label ?? o.value), value: o.value }
265
- : { label: String(o), value: o },
266
- );
267
-
268
175
  const methodLabelOf = (methodId: string, schema: TestMethod | undefined): string =>
269
176
  (schema?.label && schema.label.length > 0) ? schema.label : methodId;
270
177
 
@@ -550,10 +457,7 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
550
457
 
551
458
  for (const field of schema.config_fields) {
552
459
  if (field.name === 'sample_id') continue;
553
- const v = config[field.name];
554
- const empty = v === undefined || v === '' || v === null;
555
- if (field.required && empty) { valid = false; break; }
556
- if (rangeIssue(field, v)) { valid = false; break; }
460
+ if (fieldIssue(field, config[field.name])) { valid = false; break; }
557
461
  }
558
462
 
559
463
  setIsValid(valid);
@@ -583,23 +487,15 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
583
487
  onValidationChange, invoke,
584
488
  ]);
585
489
 
586
- const isFieldValid = (field: TestFieldDef) => {
587
- const v = config[field.name];
588
- const empty = v === undefined || v === '' || v === null;
589
- if (field.required && empty) return false;
590
- if (rangeIssue(field, v)) return false;
591
- return true;
592
- };
490
+ const isFieldValid = (field: TestFieldDef) => fieldIsValid(field, config[field.name]);
593
491
 
594
492
  const handleSampleIdChange = (value: string) => {
595
493
  setSampleIdLocal(value);
596
494
  };
597
495
 
598
- const handleFieldChange = async (field: TestFieldDef, val: any) => {
599
- // The operator typed a DISPLAY value; convert to RAW before
600
- // storing or writing to GM. Non-numeric fields and identity
601
- // scales pass through unchanged via the helper.
602
- const rawVal = displayToRaw(val, field.scale);
496
+ const handleFieldChange = async (field: TestFieldDef, rawVal: any) => {
497
+ // <TestFieldRow> has already converted the operator's DISPLAY entry
498
+ // to RAW; storage and GM always hold raw.
603
499
  setConfig({ ...config, [field.name]: rawVal });
604
500
  if (field.source) {
605
501
  try { await write(field.source, rawVal); }
@@ -642,81 +538,31 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
642
538
  const renderConfigField = (field: TestFieldDef) => {
643
539
  if (field.name === 'sample_id') return null;
644
540
  const valid = isFieldValid(field);
541
+ // A field an AMS asset_ref claims gets its picker from the registry
542
+ // instead of from the field declaration; everything else is rendered
543
+ // from the schema by <TestFieldRow>, exactly as the Cycle tab does.
645
544
  const assetType = assetTypeForField(field);
646
- // A schema-declared `options` list turns the field into a
647
- // dropdown (skipped when an asset_ref already claims the field
648
- // — that picker is sourced from AMS, not the static list).
649
- const dropdownOptions = !assetType && Array.isArray(field.options) && field.options.length > 0
650
- ? normalizeOptions(field.options)
651
- : null;
652
- const isNum = !assetType && !dropdownOptions
653
- && field.type !== 'string' && field.type !== 'bool';
654
545
  return (
655
- <React.Fragment key={field.name}>
656
- <span className="ac-form-label">{labelOf(field)}</span>
657
- {assetType ? (
546
+ <TestFieldRow
547
+ key={field.name}
548
+ field={field}
549
+ value={config[field.name]}
550
+ valid={valid}
551
+ onChange={(raw) => handleFieldChange(field, raw)}
552
+ onInfo={(f) => setInfoDialog({
553
+ open: true,
554
+ title: labelOf(f),
555
+ body: <p style={{ margin: 0, whiteSpace: 'pre-wrap' }}>{f.description}</p>,
556
+ })}
557
+ control={assetType ? (
658
558
  <AssetIdPicker
659
559
  assetType={assetType}
660
560
  value={config[field.name] != null ? String(config[field.name]) : ''}
661
561
  onChange={(val) => handleFieldChange(field, val)}
662
562
  invalid={!valid}
663
563
  />
664
- ) : dropdownOptions ? (
665
- <Dropdown
666
- value={config[field.name] ?? null}
667
- options={dropdownOptions}
668
- onChange={(e) => handleFieldChange(field, e.value)}
669
- placeholder={`Select ${field.label ?? field.name}…`}
670
- className={`ac-dropdown-clearable${!valid ? ' p-invalid' : ''}`}
671
- showClear={!field.required}
672
- />
673
- ) : isNum ? (
674
- <ValueInput
675
- label={undefined}
676
- // Storage is RAW; render the DISPLAY value so
677
- // operator sees the units they configured. The
678
- // change handler runs the inverse before
679
- // committing back to storage.
680
- value={config[field.name] != null
681
- ? Number(rawToDisplay(Number(config[field.name]), field.scale))
682
- : null}
683
- // min/max are authored in display units, matching the
684
- // value rendered above — ValueInput rejects out-of-range
685
- // entries on accept.
686
- min={field.min}
687
- max={field.max}
688
- onValueChanged={(val) => handleFieldChange(field, val)}
689
- className={!valid ? 'p-invalid' : ''}
690
- />
691
- ) : (
692
- <TextInput
693
- label={undefined}
694
- value={config[field.name] != null ? String(config[field.name]) : ''}
695
- onValueChanged={(val) => handleFieldChange(field, val)}
696
- className={!valid ? 'p-invalid' : ''}
697
- />
698
- )}
699
- {hasDescription(field) ? (
700
- <Button
701
- icon="pi pi-info-circle"
702
- text
703
- rounded
704
- aria-label={`About ${labelOf(field)}`}
705
- onClick={() => setInfoDialog({
706
- open: true,
707
- title: labelOf(field),
708
- body: <p style={{ margin: 0, whiteSpace: 'pre-wrap' }}>{field.description}</p>,
709
- })}
710
- // Replaces a hover Tooltip — touch-friendly. The
711
- // description is shown in the shared info dialog.
712
- />
713
- ) : (
714
- <span aria-hidden="true" />
715
- )}
716
- <span style={{ color: valid ? 'var(--green-500)' : 'var(--red-500)', display: 'flex', alignItems: 'center' }}>
717
- <i className={valid ? 'pi pi-check' : 'pi pi-times'} />
718
- </span>
719
- </React.Fragment>
564
+ ) : undefined}
565
+ />
720
566
  );
721
567
  };
722
568
 
@@ -772,14 +618,8 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
772
618
  if (schema) {
773
619
  for (const field of schema.config_fields) {
774
620
  if (field.name === 'sample_id') continue;
775
- const v = config[field.name];
776
- const empty = v === undefined || v === '' || v === null;
777
- if (field.required && empty) {
778
- issues.push(`Required field "${labelOf(field)}" is empty.`);
779
- continue;
780
- }
781
- const re = rangeIssue(field, v);
782
- if (re) issues.push(`"${labelOf(field)}" ${re}.`);
621
+ const issue = fieldIssue(field, config[field.name]);
622
+ if (issue) issues.push(`"${labelOf(field)}" ${issue}.`);
783
623
  }
784
624
  }
785
625
  return issues;