@adcops/autocore-react 3.3.89 → 3.3.91

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 (96) hide show
  1. package/dist/assets/JogXNeg.d.ts +4 -0
  2. package/dist/assets/JogXNeg.d.ts.map +1 -0
  3. package/dist/assets/JogXNeg.js +1 -0
  4. package/dist/assets/JogXPos.d.ts +4 -0
  5. package/dist/assets/JogXPos.d.ts.map +1 -0
  6. package/dist/assets/JogXPos.js +1 -0
  7. package/dist/assets/JogYNeg.d.ts +4 -0
  8. package/dist/assets/JogYNeg.d.ts.map +1 -0
  9. package/dist/assets/JogYNeg.js +1 -0
  10. package/dist/assets/JogYPos.d.ts +4 -0
  11. package/dist/assets/JogYPos.d.ts.map +1 -0
  12. package/dist/assets/JogYPos.js +1 -0
  13. package/dist/assets/JogZNeg.d.ts +4 -0
  14. package/dist/assets/JogZNeg.d.ts.map +1 -0
  15. package/dist/assets/JogZNeg.js +1 -0
  16. package/dist/assets/JogZPos.d.ts +4 -0
  17. package/dist/assets/JogZPos.d.ts.map +1 -0
  18. package/dist/assets/JogZPos.js +1 -0
  19. package/dist/assets/Off.d.ts +4 -0
  20. package/dist/assets/Off.d.ts.map +1 -0
  21. package/dist/assets/Off.js +1 -0
  22. package/dist/assets/On.d.ts +4 -0
  23. package/dist/assets/On.d.ts.map +1 -0
  24. package/dist/assets/On.js +1 -0
  25. package/dist/assets/index.d.ts +8 -0
  26. package/dist/assets/index.d.ts.map +1 -1
  27. package/dist/assets/index.js +1 -1
  28. package/dist/assets/svg/off.svg +2 -0
  29. package/dist/assets/svg/on.svg +11 -0
  30. package/dist/components/JogPanel.d.ts +2 -2
  31. package/dist/components/JogPanel.d.ts.map +1 -1
  32. package/dist/components/JogPanel.js +1 -1
  33. package/dist/components/ams/AssetDetailView.js +1 -1
  34. package/dist/components/ams/AssetEditDialog.d.ts.map +1 -1
  35. package/dist/components/ams/AssetEditDialog.js +1 -1
  36. package/dist/components/ams/AssetRegistryTable.css +12 -0
  37. package/dist/components/ams/AssetRegistryTable.d.ts +1 -0
  38. package/dist/components/ams/AssetRegistryTable.d.ts.map +1 -1
  39. package/dist/components/ams/AssetRegistryTable.js +1 -1
  40. package/dist/components/tis/ConfigurationDialog.d.ts +21 -0
  41. package/dist/components/tis/ConfigurationDialog.d.ts.map +1 -0
  42. package/dist/components/tis/ConfigurationDialog.js +1 -0
  43. package/dist/components/tis/ResultHistoryTable.js +1 -1
  44. package/dist/components/tis/TestDataView.d.ts +47 -0
  45. package/dist/components/tis/TestDataView.d.ts.map +1 -1
  46. package/dist/components/tis/TestDataView.js +1 -1
  47. package/dist/components/tis/TestSetupForm.d.ts +37 -0
  48. package/dist/components/tis/TestSetupForm.d.ts.map +1 -1
  49. package/dist/components/tis/TestSetupForm.js +1 -1
  50. package/dist/components/tis/TisProvider.d.ts +25 -0
  51. package/dist/components/tis/TisProvider.d.ts.map +1 -1
  52. package/dist/components/tis/TisProvider.js +1 -1
  53. package/dist/components/tis/useRawCycleData.d.ts.map +1 -1
  54. package/dist/components/tis/useRawCycleData.js +1 -1
  55. package/dist/components/tis-editor/TisConfigEditor.css +20 -0
  56. package/dist/components/tis-editor/editor/ConfigurationsEditor.d.ts +19 -0
  57. package/dist/components/tis-editor/editor/ConfigurationsEditor.d.ts.map +1 -0
  58. package/dist/components/tis-editor/editor/ConfigurationsEditor.js +1 -0
  59. package/dist/components/tis-editor/editor/MethodFormEditor.d.ts.map +1 -1
  60. package/dist/components/tis-editor/editor/MethodFormEditor.js +1 -1
  61. package/dist/components/tis-editor/types.d.ts +13 -0
  62. package/dist/components/tis-editor/types.d.ts.map +1 -1
  63. package/dist/components/tis-editor/validation.d.ts.map +1 -1
  64. package/dist/components/tis-editor/validation.js +1 -1
  65. package/dist/themes/adc-dark/blue/theme.css +17 -2
  66. package/dist/themes/adc-dark/blue/theme.css.map +1 -1
  67. package/package.json +2 -1
  68. package/src/assets/JogXNeg.tsx +30 -0
  69. package/src/assets/JogXPos.tsx +30 -0
  70. package/src/assets/JogYNeg.tsx +30 -0
  71. package/src/assets/JogYPos.tsx +30 -0
  72. package/src/assets/JogZNeg.tsx +30 -0
  73. package/src/assets/JogZPos.tsx +30 -0
  74. package/src/assets/Off.tsx +14 -0
  75. package/src/assets/On.tsx +26 -0
  76. package/src/assets/index.ts +8 -0
  77. package/src/assets/svg/off.svg +2 -0
  78. package/src/assets/svg/on.svg +11 -0
  79. package/src/components/JogPanel.tsx +18 -28
  80. package/src/components/ams/AssetDetailView.tsx +1 -1
  81. package/src/components/ams/AssetEditDialog.tsx +25 -10
  82. package/src/components/ams/AssetRegistryTable.css +12 -0
  83. package/src/components/ams/AssetRegistryTable.tsx +15 -4
  84. package/src/components/tis/ConfigurationDialog.tsx +128 -0
  85. package/src/components/tis/ResultHistoryTable.tsx +2 -2
  86. package/src/components/tis/TestDataView.tsx +270 -12
  87. package/src/components/tis/TestSetupForm.tsx +167 -10
  88. package/src/components/tis/TisProvider.tsx +53 -0
  89. package/src/components/tis/useRawCycleData.ts +22 -3
  90. package/src/components/tis-editor/TisConfigEditor.css +20 -0
  91. package/src/components/tis-editor/editor/ConfigurationsEditor.tsx +242 -0
  92. package/src/components/tis-editor/editor/MethodFormEditor.tsx +4 -0
  93. package/src/components/tis-editor/types.ts +14 -0
  94. package/src/components/tis-editor/validation.ts +29 -0
  95. package/src/themes/adc-dark/_extensions.scss +20 -0
  96. package/src/themes/theme-base/components/panel/_fieldset.scss +2 -2
@@ -37,6 +37,7 @@ import { Chart as ChartJS,
37
37
  Title, Tooltip, Legend,
38
38
  } from 'chart.js';
39
39
  import zoomPlugin from 'chartjs-plugin-zoom';
40
+ import annotationPlugin from 'chartjs-plugin-annotation';
40
41
  import { Line } from 'react-chartjs-2';
41
42
 
42
43
  import { EventEmitterContext } from '../../core/EventEmitterContext';
@@ -46,7 +47,7 @@ import { useRawCycleData } from './useRawCycleData';
46
47
 
47
48
  ChartJS.register(
48
49
  CategoryScale, LinearScale, PointElement, LineElement,
49
- Title, Tooltip, Legend, zoomPlugin,
50
+ Title, Tooltip, Legend, zoomPlugin, annotationPlugin,
50
51
  );
51
52
 
52
53
  // -------------------------------------------------------------------------
@@ -69,11 +70,58 @@ export interface TestFieldDef {
69
70
 
70
71
  export interface ChartAxis { field?: string; column?: string; label?: string; }
71
72
  export interface ChartSeries { field?: string; column?: string; label?: string; y_axis?: 'left' | 'right'; }
73
+ /**
74
+ * A shaded X-range band drawn over the plot (rendered as a
75
+ * chartjs-plugin-annotation `box` annotation spanning the full Y height).
76
+ * `xMin`/`xMax` are in the same units as the view's x data — for a
77
+ * `raw_trace` that's the x column's value, for a `cycle_scatter` it's the
78
+ * category index. Used to indicate a processed / region-of-interest span.
79
+ */
80
+ export interface ChartRegion {
81
+ /** Start of the band on the X axis. */
82
+ xMin: number;
83
+ /** End of the band on the X axis. */
84
+ xMax: number;
85
+ /** Optional caption drawn at the top-center of the band. */
86
+ label?: string;
87
+ /** Fill color (any CSS color). Default: translucent theme accent. */
88
+ color?: string;
89
+ /** Optional band-edge border color. Default: no border. */
90
+ borderColor?: string;
91
+ }
72
92
  export interface ChartView {
73
93
  title?: string;
74
94
  type: 'cycle_scatter' | 'raw_trace';
75
95
  x: ChartAxis;
76
96
  y: ChartSeries[];
97
+ /**
98
+ * Display-only line smoothing for `raw_trace` views. `undefined ⇒ true`.
99
+ *
100
+ * When on, each series is passed through a Savitzky–Golay filter (which
101
+ * removes sample-to-sample noise while keeping peak height/shape),
102
+ * lightly decimated, and drawn with monotone-cubic interpolation — a
103
+ * smooth curve that traces the profile instead of a squiggly polyline.
104
+ * The underlying `raw` data is never altered (table, CSV, and any
105
+ * calculations still see the exact samples). Set `false` to render the
106
+ * raw polyline through every sample.
107
+ */
108
+ smooth?: boolean;
109
+ /**
110
+ * Optional Savitzky–Golay window size in samples (odd; clamped to the
111
+ * series length). Larger = smoother. Default is adaptive to the sample
112
+ * count. Ignored when `smooth` is false.
113
+ */
114
+ smoothWindow?: number;
115
+ /**
116
+ * Optional shaded X-range bands drawn over the plot, declared per view
117
+ * in project.json (static). These are drawn on every cycle.
118
+ *
119
+ * For per-cycle dynamic bands (computed at runtime from processed
120
+ * data), the producer instead attaches a `regions` array to the
121
+ * raw-data envelope; the component merges those with these and renders
122
+ * both. See `dynamicRegions` in the component body.
123
+ */
124
+ regions?: ChartRegion[];
77
125
  }
78
126
  export interface RawColumn { source: string; }
79
127
  export interface RawDataShape {
@@ -325,16 +373,28 @@ export const TestDataView: React.FC<TestDataViewProps> = (props) => {
325
373
  const xCol = selectedViewDef.x.column;
326
374
  if (!xCol) return null;
327
375
  const xs = traceFetch.raw[xCol] ?? [];
328
- const datasets = selectedViewDef.y.map((s, idx) => ({
329
- label: s.label ?? s.column,
330
- data: (traceFetch.raw![s.column!] ?? []).map((y, i) => ({ x: xs[i], y })),
331
- yAxisID: s.y_axis === 'right' ? 'y1' : 'y',
332
- borderColor: palette(idx),
333
- backgroundColor: palette(idx),
334
- pointRadius: 0,
335
- borderWidth: 1.5,
336
- showLine: true,
337
- }));
376
+ // Display-only smoothing (default on). Leaves traceFetch.raw
377
+ // untouched — only what chart.js draws changes.
378
+ const smoothOn = selectedViewDef.smooth !== false;
379
+ const datasets = selectedViewDef.y.map((s, idx) => {
380
+ const ys = (traceFetch.raw![s.column!] ?? []) as number[];
381
+ const data = smoothOn
382
+ ? smoothTraceForDisplay(xs, ys, selectedViewDef.smoothWindow)
383
+ : ys.map((y, i) => ({ x: xs[i], y }));
384
+ return {
385
+ label: s.label ?? s.column,
386
+ data,
387
+ yAxisID: s.y_axis === 'right' ? 'y1' : 'y',
388
+ borderColor: palette(idx),
389
+ backgroundColor: palette(idx),
390
+ pointRadius: 0,
391
+ borderWidth: 1.5,
392
+ showLine: true,
393
+ // Smooth curve through the (already de-noised) points;
394
+ // 'monotone' avoids the overshoot a plain tension adds.
395
+ ...(smoothOn ? { cubicInterpolationMode: 'monotone' as const } : {}),
396
+ };
397
+ });
338
398
  return { datasets };
339
399
  }
340
400
  return null;
@@ -342,6 +402,28 @@ export const TestDataView: React.FC<TestDataViewProps> = (props) => {
342
402
 
343
403
  const usesRightAxis = selectedViewDef?.y.some(s => s.y_axis === 'right') ?? false;
344
404
 
405
+ // Per-cycle region bands carried in the raw-data envelope — the dynamic
406
+ // case: bands computed at runtime from processed data, changing each
407
+ // cycle. The hook re-fetches the envelope whenever the selected cycle
408
+ // changes, so these update automatically with the cycle picker.
409
+ //
410
+ // Read a top-level `regions` field first; fall back to `context.regions`
411
+ // so a producer can attach bands today (the envelope's `context` is
412
+ // already passed straight through by the server) without waiting on a
413
+ // server-side envelope-shape change.
414
+ const dynamicRegions = useMemo<ChartRegion[]>(() => {
415
+ if (!isRawTraceView) return [];
416
+ const env: any = traceFetch.envelope;
417
+ const r = env?.regions ?? env?.context?.regions;
418
+ return Array.isArray(r) ? r : [];
419
+ }, [isRawTraceView, traceFetch.envelope]);
420
+
421
+ // Static (schema-declared) bands + dynamic (per-cycle) bands, drawn together.
422
+ const activeRegions = useMemo<ChartRegion[]>(
423
+ () => [...(selectedViewDef?.regions ?? []), ...dynamicRegions],
424
+ [selectedViewDef, dynamicRegions],
425
+ );
426
+
345
427
  const chartOptions = useMemo(() => {
346
428
  const isTrace = selectedViewDef?.type === 'raw_trace';
347
429
  return {
@@ -375,9 +457,15 @@ export const TestDataView: React.FC<TestDataViewProps> = (props) => {
375
457
  mode: 'xy' as const,
376
458
  },
377
459
  },
460
+ // Region bands: the view's static `regions` declaration plus
461
+ // any per-cycle bands carried in the raw-data envelope, merged
462
+ // upstream into `activeRegions`.
463
+ annotation: {
464
+ annotations: buildRegionAnnotations(activeRegions),
465
+ },
378
466
  },
379
467
  };
380
- }, [selectedViewDef, usesRightAxis]);
468
+ }, [selectedViewDef, usesRightAxis, activeRegions]);
381
469
 
382
470
  // -----------------------------------------------------------------
383
471
  // View Raw Data dialog: lazy-fetch raw + filtered blobs the first
@@ -1010,6 +1098,176 @@ const CHART_COLORS = [
1010
1098
  ];
1011
1099
  const palette = (i: number) => CHART_COLORS[i % CHART_COLORS.length];
1012
1100
 
1101
+ // =========================================================================
1102
+ // Display-only trace smoothing (Savitzky–Golay + decimation)
1103
+ //
1104
+ // Why SG: a sliding low-order polynomial fit removes sample-to-sample noise
1105
+ // while preserving the height and width of real features (peaks) far better
1106
+ // than a moving average, which pulls peaks toward the local mean. This is
1107
+ // purely cosmetic — it runs on a copy of the y-values when building the
1108
+ // chart dataset and never touches `traceFetch.raw`, so the raw table, CSV
1109
+ // export, and any downstream math see the exact samples.
1110
+ // =========================================================================
1111
+
1112
+ // Cubic fit: preserves peak curvature better than linear/quadratic.
1113
+ const SG_ORDER = 3;
1114
+ // Target point count after decimation. The smoothed series is broad, so
1115
+ // uniform decimation to ~this many points renders cheaply and looks
1116
+ // identical to drawing all 5000.
1117
+ const SMOOTH_TARGET_POINTS = 800;
1118
+
1119
+ // Invert a small square matrix via Gauss–Jordan. Returns null if singular.
1120
+ function invertMatrix(M: number[][]): number[][] | null {
1121
+ const n = M.length;
1122
+ const A = M.map((row, i) =>
1123
+ [...row, ...Array.from({ length: n }, (_, j) => (i === j ? 1 : 0))]);
1124
+ for (let col = 0; col < n; col++) {
1125
+ let piv = col;
1126
+ for (let r = col + 1; r < n; r++)
1127
+ if (Math.abs(A[r][col]) > Math.abs(A[piv][col])) piv = r;
1128
+ if (Math.abs(A[piv][col]) < 1e-12) return null;
1129
+ [A[col], A[piv]] = [A[piv], A[col]];
1130
+ const d = A[col][col];
1131
+ for (let j = 0; j < 2 * n; j++) A[col][j] /= d;
1132
+ for (let r = 0; r < n; r++) {
1133
+ if (r === col) continue;
1134
+ const f = A[r][col];
1135
+ if (f === 0) continue;
1136
+ for (let j = 0; j < 2 * n; j++) A[r][j] -= f * A[col][j];
1137
+ }
1138
+ }
1139
+ return A.map(row => row.slice(n));
1140
+ }
1141
+
1142
+ // Savitzky–Golay smoothing weights for a window of half-width `h`
1143
+ // (size 2h+1) and polynomial `order`. The center smoothed value is the
1144
+ // constant term of the local least-squares polynomial fit, i.e. row 0 of
1145
+ // (AᵀA)⁻¹Aᵀ. Weights depend only on (h, order) → computed once, cached.
1146
+ function sgWeights(h: number, order: number): number[] {
1147
+ const win = 2 * h + 1;
1148
+ const ord = Math.min(order, win - 1);
1149
+ const A: number[][] = [];
1150
+ for (let z = -h; z <= h; z++) {
1151
+ const row: number[] = [];
1152
+ let zp = 1;
1153
+ for (let c = 0; c <= ord; c++) { row.push(zp); zp *= z; }
1154
+ A.push(row);
1155
+ }
1156
+ const m = ord + 1;
1157
+ const M: number[][] = Array.from({ length: m }, () => new Array(m).fill(0));
1158
+ for (let a = 0; a < m; a++)
1159
+ for (let b = 0; b < m; b++) {
1160
+ let s = 0;
1161
+ for (let r = 0; r < win; r++) s += A[r][a] * A[r][b];
1162
+ M[a][b] = s;
1163
+ }
1164
+ const Minv = invertMatrix(M);
1165
+ if (!Minv) return new Array(win).fill(1 / win); // degenerate → flat mean
1166
+ const w = new Array<number>(win);
1167
+ for (let r = 0; r < win; r++) {
1168
+ let s = 0;
1169
+ for (let a = 0; a < m; a++) s += Minv[0][a] * A[r][a];
1170
+ w[r] = s;
1171
+ }
1172
+ return w;
1173
+ }
1174
+
1175
+ // Apply SG smoothing to a y-series. Near the ends the window shrinks
1176
+ // symmetrically (h = distance to the nearest edge) so the filter stays
1177
+ // centered and no phantom data is invented past the trace boundaries.
1178
+ function savitzkyGolay(ys: number[], halfWidth: number): number[] {
1179
+ const n = ys.length;
1180
+ if (n === 0 || halfWidth < 1) return ys.slice();
1181
+ const cache = new Map<number, number[]>();
1182
+ const weightsFor = (h: number) => {
1183
+ let w = cache.get(h);
1184
+ if (!w) { w = sgWeights(h, SG_ORDER); cache.set(h, w); }
1185
+ return w;
1186
+ };
1187
+ const out = new Array<number>(n);
1188
+ for (let i = 0; i < n; i++) {
1189
+ const h = Math.min(halfWidth, i, n - 1 - i);
1190
+ if (h < 1) { out[i] = ys[i]; continue; }
1191
+ const w = weightsFor(h);
1192
+ let s = 0;
1193
+ for (let k = -h; k <= h; k++) s += w[k + h] * ys[i + k];
1194
+ out[i] = s;
1195
+ }
1196
+ return out;
1197
+ }
1198
+
1199
+ // Build the {x, y} points chart.js draws for a smoothed raw_trace series:
1200
+ // SG-denoise, then uniformly decimate the broad result to SMOOTH_TARGET_POINTS.
1201
+ function smoothTraceForDisplay(
1202
+ xs: number[],
1203
+ ys: number[],
1204
+ window?: number,
1205
+ ): { x: number; y: number }[] {
1206
+ const n = Math.min(xs.length, ys.length);
1207
+ if (n === 0) return [];
1208
+ // Adaptive default half-width: wide enough to suppress noise, narrow
1209
+ // enough to keep features. An explicit `smoothWindow` (full odd size)
1210
+ // overrides it.
1211
+ const half = window && window > 1
1212
+ ? Math.max(1, Math.floor((Math.min(window, n) - 1) / 2))
1213
+ : Math.max(2, Math.min(40, Math.round(n / 250)));
1214
+ const sm = savitzkyGolay(ys.slice(0, n), half);
1215
+ const stride = Math.max(1, Math.ceil(n / SMOOTH_TARGET_POINTS));
1216
+ const out: { x: number; y: number }[] = [];
1217
+ for (let i = 0; i < n; i += stride) out.push({ x: xs[i], y: sm[i] });
1218
+ // Always anchor the final sample so the curve reaches the trace end.
1219
+ if ((n - 1) % stride !== 0) out.push({ x: xs[n - 1], y: sm[n - 1] });
1220
+ return out;
1221
+ }
1222
+
1223
+ // Default translucent fill for region bands (theme accent at low alpha).
1224
+ const DEFAULT_REGION_FILL = 'rgba(78, 168, 222, 0.15)';
1225
+ // Label color must be a concrete canvas color — CSS variables don't
1226
+ // resolve when chart.js paints to the 2D context.
1227
+ const REGION_LABEL_COLOR = 'rgba(226, 232, 240, 0.85)';
1228
+
1229
+ /**
1230
+ * Translate the view's `regions` into chartjs-plugin-annotation `box`
1231
+ * annotations keyed by a stable id. Each band spans the full Y height
1232
+ * (yMin/yMax left unset) between `xMin` and `xMax` on the x scale.
1233
+ * Returns an empty map when there are no regions, which the plugin
1234
+ * treats as "draw nothing".
1235
+ *
1236
+ * This is the single seam through which both the static schema path and a
1237
+ * future dynamic (processed-data) path produce annotations.
1238
+ */
1239
+ function buildRegionAnnotations(regions?: ChartRegion[]): Record<string, any> {
1240
+ if (!regions || regions.length === 0) return {};
1241
+ const out: Record<string, any> = {};
1242
+ regions.forEach((r, i) => {
1243
+ const hasBorder = !!r.borderColor;
1244
+ out[`region-${i}`] = {
1245
+ type: 'box',
1246
+ xScaleID: 'x',
1247
+ xMin: r.xMin,
1248
+ xMax: r.xMax,
1249
+ // yMin/yMax intentionally omitted → band spans the plot height.
1250
+ backgroundColor: r.color ?? DEFAULT_REGION_FILL,
1251
+ borderColor: hasBorder ? r.borderColor : 'transparent',
1252
+ borderWidth: hasBorder ? 1 : 0,
1253
+ // Sit behind the data line so the trace stays readable.
1254
+ drawTime: 'beforeDatasetsDraw' as const,
1255
+ ...(r.label
1256
+ ? {
1257
+ label: {
1258
+ display: true,
1259
+ content: r.label,
1260
+ position: { x: 'center' as const, y: 'start' as const },
1261
+ color: REGION_LABEL_COLOR,
1262
+ font: { size: 11 },
1263
+ },
1264
+ }
1265
+ : {}),
1266
+ };
1267
+ });
1268
+ return out;
1269
+ }
1270
+
1013
1271
  // Loading / error wash drawn over the chart area while a raw_trace
1014
1272
  // fetch is in flight. Centered, pointer-events-none so the operator
1015
1273
  // can still interact with the dropdown above.
@@ -1,4 +1,4 @@
1
- import React, { useState, useEffect, useContext, useMemo, useRef } from 'react';
1
+ import React, { useState, useEffect, useContext, useMemo } from 'react';
2
2
  import { Button } from 'primereact/button';
3
3
  import { InputText } from 'primereact/inputtext';
4
4
  import { Dropdown } from 'primereact/dropdown';
@@ -11,6 +11,7 @@ import { TextInput } from '../TextInput';
11
11
  import { useTis } from './TisProvider';
12
12
  import { useAmsAssets, useAmsRoles, type AmsAssetEntry } from '../ams/AmsProvider';
13
13
  import { TestMethodDialog } from './TestMethodDialog';
14
+ import { ConfigurationDialog, configLabelOf } from './ConfigurationDialog';
14
15
 
15
16
  /**
16
17
  * One asset_ref declared on a test method. We only consume the subset
@@ -59,6 +60,19 @@ export interface TestFieldDef {
59
60
  * Cycle and results values are scaled by the corresponding paths
60
61
  * in TestDataView; the server scales CSV exports too. */
61
62
  scale?: number;
63
+ /** Optional fixed set of choices. When present, the field renders
64
+ * as a dropdown and the operator must pick one of the declared
65
+ * values rather than typing freely. Each entry is either a bare
66
+ * scalar (label === value) or an explicit `{ label, value }` pair
67
+ * when the displayed text should differ from the stored value.
68
+ * Works with any `type`; like `default`, values are authored in
69
+ * display units when `scale` is set. */
70
+ options?: Array<
71
+ | string
72
+ | number
73
+ | boolean
74
+ | { label?: string; value: string | number | boolean }
75
+ >;
62
76
  }
63
77
 
64
78
  export interface TestMethod {
@@ -90,6 +104,33 @@ export interface TestMethod {
90
104
  * as `analysis` — the form ignores them; the type just has to
91
105
  * accept them so generated schemas typecheck. */
92
106
  views?: Record<string, any>;
107
+ /**
108
+ * Optional named configurations for this method. When one or more
109
+ * are declared, the form renders a Configuration selector beneath
110
+ * the Test Method picker. Selecting (or accepting) a configuration
111
+ * writes its `defaults` into the matching config_fields. Each
112
+ * configuration only needs to declare the fields whose value is
113
+ * specific to it — unlisted fields keep the method's base default.
114
+ */
115
+ configurations?: TestConfiguration[];
116
+ }
117
+
118
+ /**
119
+ * One named, ready-to-use set of config_field overrides for a method —
120
+ * e.g. translational_traction's "Plaque" vs "Shoe". `defaults` is a
121
+ * sparse map of `config_field.name → value`; values are authored in
122
+ * display units (the form converts via the field's `scale` before
123
+ * writing to stagedConfig / GM, same convention as `TestFieldDef.default`).
124
+ */
125
+ export interface TestConfiguration {
126
+ /** Canonical key — unique within the method. */
127
+ name: string;
128
+ /** Pretty label shown in the Configuration picker. Falls back to `name`. */
129
+ label?: string;
130
+ /** Long-form guidance shown beside the dropdown in the picker dialog. */
131
+ description?: string;
132
+ /** Sparse `config_field.name → value` overrides applied on selection. */
133
+ defaults?: Record<string, any>;
93
134
  }
94
135
 
95
136
  /**
@@ -145,6 +186,20 @@ const displayToRaw = (display: any, scale: number | undefined): any => {
145
186
  const hasDescription = (f: TestFieldDef): boolean =>
146
187
  typeof f.description === 'string' && f.description.length > 0;
147
188
 
189
+ /**
190
+ * Normalise a field's `options` (bare scalars and/or `{label, value}`
191
+ * pairs) into the `{ label, value }[]` shape PrimeReact's Dropdown
192
+ * wants. Bare scalars use their stringified form as the label.
193
+ */
194
+ const normalizeOptions = (
195
+ opts: TestFieldDef['options'],
196
+ ): { label: string; value: string | number | boolean }[] =>
197
+ (opts ?? []).map((o) =>
198
+ o !== null && typeof o === 'object'
199
+ ? { label: String(o.label ?? o.value), value: o.value }
200
+ : { label: String(o), value: o },
201
+ );
202
+
148
203
  const methodLabelOf = (methodId: string, schema: TestMethod | undefined): string =>
149
204
  (schema?.label && schema.label.length > 0) ? schema.label : methodId;
150
205
 
@@ -240,6 +295,28 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
240
295
 
241
296
  const schema = schemaOverride ?? (methodId ? tis.schemas[methodId] : undefined);
242
297
 
298
+ // Write one configuration's sparse `defaults` overrides into a config
299
+ // object in place (returns the same object for chaining). Mirrors the
300
+ // base-default seeding below: values are authored in display units, so
301
+ // we convert to raw before storing; source-bound fields are also
302
+ // pushed to GM so the control program sees them. Used both by the
303
+ // auto-apply-first effect and by the picker dialog's accept handler.
304
+ const applyConfigOverridesInto = (cfg: TestConfiguration, target: any): any => {
305
+ for (const [fieldName, val] of Object.entries(cfg.defaults ?? {})) {
306
+ if (fieldName === 'sample_id') continue;
307
+ const field = schema?.config_fields.find((f: TestFieldDef) => f.name === fieldName);
308
+ const rawVal = displayToRaw(val, field?.scale);
309
+ target[fieldName] = rawVal;
310
+ if (field?.source) {
311
+ void Promise.resolve()
312
+ .then(() => write(field.source!, rawVal))
313
+ .catch(e => console.error(
314
+ `[TestSetupForm] Failed to apply configuration override for ${fieldName}:`, e));
315
+ }
316
+ }
317
+ return target;
318
+ };
319
+
243
320
  useEffect(() => {
244
321
  if (tis.selection.methodId !== methodId && methodId) {
245
322
  tis.setSelection({ methodId });
@@ -274,6 +351,19 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
274
351
 
275
352
  const [isValid, setIsValid] = useState(false);
276
353
  const [methodPickerOpen, setMethodPickerOpen] = useState(false);
354
+ const [configPickerOpen, setConfigPickerOpen] = useState(false);
355
+
356
+ const configurations = (schema?.configurations ?? []) as TestConfiguration[];
357
+ const selectedConfig = configurations.find(c => c.name === tis.configurationName);
358
+
359
+ // Operator accepted a configuration in the picker dialog — write its
360
+ // overrides into the fields and remember the selection.
361
+ const handleConfigurationSelected = (configName: string) => {
362
+ const cfg = configurations.find(c => c.name === configName);
363
+ if (!cfg) return;
364
+ setConfig((prev: any) => applyConfigOverridesInto(cfg, { ...prev }));
365
+ tis.setConfigurationName(configName);
366
+ };
277
367
 
278
368
  // Single shared "info" dialog used by:
279
369
  // - the Test Setup status button (shows what's wrong, including
@@ -288,14 +378,23 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
288
378
  // Apply schema-declared defaults when the operator picks a method.
289
379
  // Source-bound fields write the default to GM (the control program
290
380
  // is the consumer of record); non-source fields land directly in
291
- // stagedConfig. We track the last method we seeded so subsequent
292
- // re-renders don't clobber operator edits only an actual method
293
- // change re-applies the defaults.
294
- const defaultsAppliedFor = useRef<string>('');
381
+ // stagedConfig. The "already seeded" marker lives on the provider
382
+ // (tis.defaultsAppliedForMethod), not in a local ref, so it
383
+ // survives this form unmounting when the operator switches tabs —
384
+ // otherwise defaults would re-apply on every Test-tab return and
385
+ // clobber the operator's edits. Only an actual method change (or
386
+ // clearStagedConfig() after start_test) re-applies the defaults.
295
387
  useEffect(() => {
296
388
  if (!schema || !methodId) return;
297
- if (defaultsAppliedFor.current === methodId) return;
298
- defaultsAppliedFor.current = methodId;
389
+ if (tis.defaultsAppliedForMethod === methodId) return;
390
+ tis.markDefaultsAppliedForMethod(methodId);
391
+
392
+ // Auto-apply the method's first configuration (if any) on top of
393
+ // the base defaults. '' when the method declares none, which also
394
+ // clears any stale selection carried over from a previous method.
395
+ const firstConfig = (schema.configurations && schema.configurations.length > 0)
396
+ ? schema.configurations[0] as TestConfiguration
397
+ : undefined;
299
398
 
300
399
  setConfig((prev: any) => {
301
400
  let next = prev;
@@ -321,9 +420,17 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
321
420
  `[TestSetupForm] Failed to seed default for ${field.name}:`, e));
322
421
  }
323
422
  }
423
+ // Configuration overrides win over base defaults for the
424
+ // fields they name (e.g. Plaque's z_start_position).
425
+ if (firstConfig) {
426
+ if (next === prev) next = { ...prev };
427
+ next = applyConfigOverridesInto(firstConfig, next);
428
+ }
324
429
  return next;
325
430
  });
326
- }, [schema, methodId, write]);
431
+
432
+ tis.setConfigurationName(firstConfig ? firstConfig.name : '');
433
+ }, [schema, methodId, write, tis.defaultsAppliedForMethod, tis.markDefaultsAppliedForMethod, tis.setConfigurationName]);
327
434
 
328
435
  // Seed and live-update config_fields that declare a `source`.
329
436
  useEffect(() => {
@@ -451,7 +558,14 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
451
558
  if (field.name === 'sample_id') return null;
452
559
  const valid = isFieldValid(field);
453
560
  const assetType = assetTypeForField(field);
454
- const isNum = !assetType && field.type !== 'string' && field.type !== 'bool';
561
+ // A schema-declared `options` list turns the field into a
562
+ // dropdown (skipped when an asset_ref already claims the field
563
+ // — that picker is sourced from AMS, not the static list).
564
+ const dropdownOptions = !assetType && Array.isArray(field.options) && field.options.length > 0
565
+ ? normalizeOptions(field.options)
566
+ : null;
567
+ const isNum = !assetType && !dropdownOptions
568
+ && field.type !== 'string' && field.type !== 'bool';
455
569
  return (
456
570
  <React.Fragment key={field.name}>
457
571
  <span className="ac-form-label">{labelOf(field)}</span>
@@ -462,6 +576,15 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
462
576
  onChange={(val) => handleFieldChange(field, val)}
463
577
  invalid={!valid}
464
578
  />
579
+ ) : dropdownOptions ? (
580
+ <Dropdown
581
+ value={config[field.name] ?? null}
582
+ options={dropdownOptions}
583
+ onChange={(e) => handleFieldChange(field, e.value)}
584
+ placeholder={`Select ${field.label ?? field.name}…`}
585
+ className={!valid ? 'p-invalid' : ''}
586
+ showClear={!field.required}
587
+ />
465
588
  ) : isNum ? (
466
589
  <ValueInput
467
590
  label={undefined}
@@ -664,7 +787,7 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
664
787
  tabIndex={-1}
665
788
  />
666
789
  <Button
667
- icon="pi pi-pencil"
790
+ icon="pi pi-folder"
668
791
  type="button"
669
792
  onClick={() => setMethodPickerOpen(true)}
670
793
  tooltip={methodIds.length > 1
@@ -680,6 +803,32 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
680
803
  </>
681
804
  )}
682
805
 
806
+ {configurations.length > 0 && (
807
+ <>
808
+ <span className="ac-form-label">Configuration</span>
809
+ <div className="p-inputgroup" style={{ flex: 1 }}>
810
+ <InputText
811
+ value={selectedConfig ? configLabelOf(selectedConfig) : ''}
812
+ placeholder="No configuration selected"
813
+ readOnly
814
+ style={{ flex: 1 }}
815
+ tabIndex={-1}
816
+ />
817
+ <Button
818
+ icon="pi pi-folder"
819
+ type="button"
820
+ onClick={() => setConfigPickerOpen(true)}
821
+ tooltip="Change configuration"
822
+ tooltipOptions={{ position: 'top' }}
823
+ />
824
+ </div>
825
+ <span aria-hidden="true" />
826
+ <span style={{ color: selectedConfig ? 'var(--green-500)' : 'var(--text-secondary-color)', display: 'flex', alignItems: 'center' }}>
827
+ <i className={selectedConfig ? 'pi pi-check' : 'pi pi-minus'} />
828
+ </span>
829
+ </>
830
+ )}
831
+
683
832
  <h3 className="ac-form-section" style={{ marginTop: '1rem' }}>Test Configuration</h3>
684
833
  {schema.config_fields.map(renderConfigField)}
685
834
 
@@ -690,6 +839,14 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
690
839
  onSelected={(picked) => setMethodIdLocal(picked)}
691
840
  />
692
841
 
842
+ <ConfigurationDialog
843
+ visible={configPickerOpen}
844
+ onHide={() => setConfigPickerOpen(false)}
845
+ configurations={configurations}
846
+ currentConfigName={tis.configurationName}
847
+ onSelected={handleConfigurationSelected}
848
+ />
849
+
693
850
  {/* Shared info dialog. Driven by the Test Setup status
694
851
  button (validation + server errors) and by per-field
695
852
  info buttons (replaces the old hover Tooltip). One