@adcops/autocore-react 3.3.105 → 3.3.109

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 (35) hide show
  1. package/dist/components/index.d.ts +2 -0
  2. package/dist/components/index.d.ts.map +1 -1
  3. package/dist/components/index.js +1 -1
  4. package/dist/components/tis/ResultHistoryTable.css +12 -0
  5. package/dist/components/tis/ResultHistoryTable.d.ts +1 -0
  6. package/dist/components/tis/ResultHistoryTable.d.ts.map +1 -1
  7. package/dist/components/tis/ResultHistoryTable.js +1 -1
  8. package/dist/components/tis/SampleSummaryPanel.d.ts +23 -0
  9. package/dist/components/tis/SampleSummaryPanel.d.ts.map +1 -0
  10. package/dist/components/tis/SampleSummaryPanel.js +1 -0
  11. package/dist/components/tis/TestDataView.d.ts.map +1 -1
  12. package/dist/components/tis/TestDataView.js +1 -1
  13. package/dist/components/tis/TestSetupForm.d.ts +5 -0
  14. package/dist/components/tis/TestSetupForm.d.ts.map +1 -1
  15. package/dist/components/tis/TestSetupForm.js +1 -1
  16. package/dist/components/tis-editor/editor/FieldArrayEditor.d.ts.map +1 -1
  17. package/dist/components/tis-editor/editor/FieldArrayEditor.js +1 -1
  18. package/dist/components/tis-editor/editor/TestFieldDialog.d.ts +6 -0
  19. package/dist/components/tis-editor/editor/TestFieldDialog.d.ts.map +1 -1
  20. package/dist/components/tis-editor/editor/TestFieldDialog.js +1 -1
  21. package/dist/components/tis-editor/types.d.ts +20 -0
  22. package/dist/components/tis-editor/types.d.ts.map +1 -1
  23. package/dist/components/tis-editor/validation.d.ts.map +1 -1
  24. package/dist/components/tis-editor/validation.js +1 -1
  25. package/package.json +1 -1
  26. package/src/components/index.ts +3 -0
  27. package/src/components/tis/ResultHistoryTable.css +12 -0
  28. package/src/components/tis/ResultHistoryTable.tsx +61 -0
  29. package/src/components/tis/SampleSummaryPanel.tsx +171 -0
  30. package/src/components/tis/TestDataView.tsx +47 -1
  31. package/src/components/tis/TestSetupForm.tsx +104 -15
  32. package/src/components/tis-editor/editor/FieldArrayEditor.tsx +18 -1
  33. package/src/components/tis-editor/editor/TestFieldDialog.tsx +139 -3
  34. package/src/components/tis-editor/types.ts +22 -0
  35. package/src/components/tis-editor/validation.ts +38 -0
@@ -6,6 +6,8 @@ import { Tag } from 'primereact/tag';
6
6
  import { EventEmitterContext } from '../../core/EventEmitterContext';
7
7
  import { MessageType } from '../../hub/CommandMessage';
8
8
  import { useTis } from './TisProvider';
9
+ import { SampleSummaryPanel } from './SampleSummaryPanel';
10
+ import './ResultHistoryTable.css';
9
11
 
10
12
  export interface ResultHistoryTableProps {
11
13
  /**
@@ -72,6 +74,7 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
72
74
  const [loading, setLoading] = useState(false);
73
75
  const [downloading, setDownloading] = useState<InFlight | null>(null);
74
76
  const [projectBusy, setProjectBusy] = useState<ProjectDownloadKind | null>(null);
77
+ const [excludingRunId, setExcludingRunId] = useState<string | null>(null);
75
78
  const { invoke } = useContext(EventEmitterContext);
76
79
 
77
80
  const loadTests = async () => {
@@ -169,6 +172,34 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
169
172
  }
170
173
  };
171
174
 
175
+ // Toggle a run's inclusion in cross-test aggregation. Excluded runs are
176
+ // kept on disk (not deleted) but dropped from avg/min/max/stddev/count
177
+ // results for their sample group. The server recomputes + rebroadcasts
178
+ // the sample summary on success.
179
+ const handleToggleExclude = async (rowData: any) => {
180
+ const runId = rowData?.run_id;
181
+ const rowMethodId = rowData?.method_id ?? methodId;
182
+ if (!runId || !rowMethodId || !projectId) return;
183
+ const nextExcluded = rowData?.excluded !== true;
184
+ setExcludingRunId(runId);
185
+ try {
186
+ const resp: any = await invoke('tis.set_test_excluded' as any, MessageType.Request, {
187
+ project_id: projectId, method_id: rowMethodId, run_id: runId, excluded: nextExcluded,
188
+ } as any);
189
+ if (!resp?.success) {
190
+ alert(`Failed to ${nextExcluded ? 'exclude' : 'include'} ${runId}` +
191
+ (resp?.error_message ? `: ${resp.error_message}` : ''));
192
+ return;
193
+ }
194
+ await loadTests();
195
+ } catch (err) {
196
+ console.error('Failed to toggle exclusion', err);
197
+ alert(`Exclude toggle failed: ${err instanceof Error ? err.message : String(err)}`);
198
+ } finally {
199
+ setExcludingRunId(null);
200
+ }
201
+ };
202
+
172
203
  const handleProjectDownload = async (kind: ProjectDownloadKind) => {
173
204
  if (!projectId) return;
174
205
  const topic = kind === 'report' ? 'tis.export_project_csv'
@@ -284,6 +315,11 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
284
315
  </div>
285
316
  </div>
286
317
 
318
+ {/* Per-sample cross-test rollup. Renders nothing unless a method
319
+ declares aggregate results_fields, so projects without the
320
+ feature see the history table unchanged. */}
321
+ <SampleSummaryPanel projectId={projectId} methodId={methodId} />
322
+
287
323
  <DataTable
288
324
  value={tests}
289
325
  loading={loading}
@@ -294,6 +330,7 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
294
330
  scrollHeight="flex"
295
331
  tableStyle={{ minWidth: 0 }}
296
332
  style={{ width: '100%' }}
333
+ rowClassName={(rowData: any) => (rowData?.excluded === true ? 'tis-row-excluded' : '')}
297
334
  selectionMode="single"
298
335
  onSelectionChange={(e: any) => {
299
336
  const row = e.value;
@@ -335,6 +372,30 @@ export const ResultHistoryTable: React.FC<ResultHistoryTableProps> = (props) =>
335
372
  }}
336
373
  style={{ minWidth: '8rem' }}
337
374
  />
375
+ <Column
376
+ header="Include"
377
+ style={{ width: '8rem' }}
378
+ body={(rowData) => {
379
+ const isExcluded = rowData?.excluded === true;
380
+ const isBusy = excludingRunId === rowData.run_id;
381
+ return (
382
+ <Button
383
+ icon={isBusy ? 'pi pi-spin pi-spinner'
384
+ : (isExcluded ? 'pi pi-eye-slash' : 'pi pi-check')}
385
+ label={isExcluded ? 'Excluded' : 'Included'}
386
+ size="small"
387
+ outlined
388
+ severity={isExcluded ? 'warning' : 'success'}
389
+ disabled={excludingRunId !== null}
390
+ onClick={() => handleToggleExclude(rowData)}
391
+ tooltip={isExcluded
392
+ ? 'Excluded from cross-test results — click to include'
393
+ : 'Included in cross-test results — click to exclude'}
394
+ tooltipOptions={{ position: 'left' }}
395
+ />
396
+ );
397
+ }}
398
+ />
338
399
  <Column
339
400
  header="Download"
340
401
  style={{ width: '14rem' }}
@@ -0,0 +1,171 @@
1
+ import React, { useState, useEffect, useContext } from 'react';
2
+ import { DataTable } from 'primereact/datatable';
3
+ import { Column } from 'primereact/column';
4
+ import { Dropdown } from 'primereact/dropdown';
5
+ import { EventEmitterContext } from '../../core/EventEmitterContext';
6
+ import { MessageType } from '../../hub/CommandMessage';
7
+ import { useTis } from './TisProvider';
8
+
9
+ /**
10
+ * Per-method cross-test rollup, one row per sample_id.
11
+ *
12
+ * Distinct from the per-run results overlay in <TestDataView>: this panel
13
+ * answers "across every run of sample X, what's the avg/min/max/stddev/count?"
14
+ * — the question operators ask when a sample is run repeatedly. Backed by
15
+ * `tis.sample_summaries`; refreshes on the server's `tis.sample_summary_updated`
16
+ * broadcast (a sibling finishing or an exclusion toggle).
17
+ *
18
+ * Renders nothing unless some method in the project declares aggregate
19
+ * results_fields. When `methodId` is omitted and more than one method has
20
+ * aggregates, a small method picker is shown.
21
+ */
22
+ export interface SampleSummaryPanelProps {
23
+ /** Defaults to the active project from <TisProvider>. */
24
+ projectId?: string;
25
+ /** Lock the panel to one method. When omitted, the user picks among
26
+ * methods that declare aggregate results_fields. */
27
+ methodId?: string;
28
+ }
29
+
30
+ interface SummaryRow {
31
+ sample_id: string;
32
+ summary: Record<string, unknown>;
33
+ included_count: number;
34
+ excluded_count: number;
35
+ }
36
+
37
+ interface AggColumn {
38
+ name: string;
39
+ header: string;
40
+ scale?: number;
41
+ }
42
+
43
+ export const SampleSummaryPanel: React.FC<SampleSummaryPanelProps> = (props) => {
44
+ const tis = useTis();
45
+ const { invoke, subscribe, unsubscribe } = useContext(EventEmitterContext);
46
+ const projectId = props.projectId ?? tis.selection.projectId ?? undefined;
47
+
48
+ // Methods that actually declare an aggregate results_field — the only
49
+ // ones worth showing here.
50
+ const methodsWithAggregates = React.useMemo(() => {
51
+ return Object.entries(tis.schemas)
52
+ .filter(([, m]) => ((m?.results_fields ?? []) as any[]).some((f) => f?.aggregate))
53
+ .map(([id]) => id);
54
+ }, [tis.schemas]);
55
+
56
+ const [picked, setPicked] = useState<string | undefined>(undefined);
57
+ const methodId = props.methodId ?? picked ?? methodsWithAggregates[0];
58
+
59
+ // Keep the local pick valid as schemas load / change.
60
+ useEffect(() => {
61
+ if (props.methodId) return;
62
+ if (!picked || !methodsWithAggregates.includes(picked)) {
63
+ setPicked(methodsWithAggregates[0]);
64
+ }
65
+ }, [props.methodId, picked, methodsWithAggregates]);
66
+
67
+ const [rows, setRows] = useState<SummaryRow[]>([]);
68
+ const [loading, setLoading] = useState(false);
69
+
70
+ // Columns come from the schema's aggregate results_fields (label + units),
71
+ // so empty-but-declared columns still show with a blank cell.
72
+ const aggColumns: AggColumn[] = React.useMemo(() => {
73
+ const schema = methodId ? (tis.schemas[methodId] as any) : undefined;
74
+ const fields: any[] = (schema?.results_fields ?? []).filter((f: any) => f?.aggregate);
75
+ return fields.map((f) => ({
76
+ name: f.name,
77
+ header: (f.label || f.name) + (f.units ? ` [${f.units}]` : ''),
78
+ scale: typeof f.scale === 'number' ? f.scale : undefined,
79
+ }));
80
+ }, [tis.schemas, methodId]);
81
+
82
+ useEffect(() => {
83
+ if (!projectId || !methodId) { setRows([]); return; }
84
+ let cancelled = false;
85
+ const load = async () => {
86
+ setLoading(true);
87
+ try {
88
+ const resp: any = await invoke('tis.sample_summaries' as any, MessageType.Request,
89
+ { project_id: projectId, method_id: methodId } as any);
90
+ if (!cancelled && resp?.success) setRows(resp.data?.summaries ?? []);
91
+ } catch (err) {
92
+ console.error('Failed to load sample summaries', err);
93
+ } finally {
94
+ if (!cancelled) setLoading(false);
95
+ }
96
+ };
97
+ load();
98
+ // Any rollup change for this project+method refreshes the whole panel
99
+ // (cheap; also picks up brand-new sample groups the broadcast can't
100
+ // patch in place). Also follow the live run lifecycle like the table.
101
+ const onSummary = (payload: any) => {
102
+ if (payload?.project_id === projectId && payload?.method_id === methodId) load();
103
+ };
104
+ const id = subscribe('tis.sample_summary_updated', onSummary);
105
+ return () => { cancelled = true; unsubscribe(id); };
106
+ // eslint-disable-next-line react-hooks/exhaustive-deps
107
+ }, [projectId, methodId, tis.state.activeRunId, tis.state.active]);
108
+
109
+ // Feature not configured for this project → render nothing.
110
+ if (methodsWithAggregates.length === 0) return null;
111
+
112
+ const fmt = (v: unknown, scale?: number): string => {
113
+ if (v === null || v === undefined) return '—';
114
+ if (typeof v === 'number') {
115
+ if (!Number.isFinite(v)) return String(v);
116
+ const scaled = typeof scale === 'number' && scale !== 0 ? v * scale : v;
117
+ return Number.parseFloat(scaled.toPrecision(6)).toString();
118
+ }
119
+ return String(v);
120
+ };
121
+
122
+ return (
123
+ <div style={{ width: '100%', maxWidth: '100%', overflow: 'hidden', boxSizing: 'border-box', marginBottom: '1rem' }}>
124
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem', gap: '0.5rem', flexWrap: 'wrap' }}>
125
+ <h3 style={{ margin: 0 }}>Sample Summary</h3>
126
+ {!props.methodId && methodsWithAggregates.length > 1 && (
127
+ <Dropdown
128
+ value={methodId}
129
+ options={methodsWithAggregates.map((id) => ({ label: id, value: id }))}
130
+ onChange={(e) => setPicked(e.value)}
131
+ style={{ minWidth: '12rem' }}
132
+ />
133
+ )}
134
+ </div>
135
+ <DataTable
136
+ value={rows}
137
+ loading={loading}
138
+ dataKey="sample_id"
139
+ emptyMessage="No samples yet."
140
+ scrollable
141
+ tableStyle={{ minWidth: 0 }}
142
+ style={{ width: '100%' }}
143
+ size="small"
144
+ stripedRows
145
+ >
146
+ <Column field="sample_id" header="Sample ID" sortable style={{ minWidth: '8rem' }} />
147
+ {aggColumns.map((c) => (
148
+ <Column
149
+ key={c.name}
150
+ header={c.header}
151
+ bodyStyle={{ fontVariantNumeric: 'tabular-nums', textAlign: 'right' }}
152
+ style={{ minWidth: '7rem' }}
153
+ body={(row: SummaryRow) => fmt(row.summary?.[c.name], c.scale)}
154
+ />
155
+ ))}
156
+ <Column
157
+ header="# Tests"
158
+ bodyStyle={{ textAlign: 'right' }}
159
+ style={{ width: '6rem' }}
160
+ body={(row: SummaryRow) => row.included_count}
161
+ />
162
+ <Column
163
+ header="Excluded"
164
+ bodyStyle={{ textAlign: 'right' }}
165
+ style={{ width: '6rem' }}
166
+ body={(row: SummaryRow) => (row.excluded_count > 0 ? row.excluded_count : '—')}
167
+ />
168
+ </DataTable>
169
+ </div>
170
+ );
171
+ };
@@ -199,6 +199,10 @@ export const TestDataView: React.FC<TestDataViewProps> = (props) => {
199
199
  const [meta, setMeta] = useState<any>(null);
200
200
  const [cycles, setCycles] = useState<any[]>([]);
201
201
  const [results, setResults] = useState<any>({});
202
+ // Server-derived cross-test aggregate results_fields (avg/min/max/stddev/
203
+ // count over the sample group). Overlaid on `results` for display — these
204
+ // fields are never in the per-test results blob.
205
+ const [sampleSummary, setSampleSummary] = useState<any>({});
202
206
  const [rawOpen, setRawOpen] = useState(false);
203
207
  const [configOpen, setConfigOpen] = useState(false);
204
208
 
@@ -351,6 +355,45 @@ export const TestDataView: React.FC<TestDataViewProps> = (props) => {
351
355
  // eslint-disable-next-line react-hooks/exhaustive-deps
352
356
  }, [projectId, methodId, runId, throttleMs]);
353
357
 
358
+ // -----------------------------------------------------------------
359
+ // Cross-test aggregate summary (sample-group rollup)
360
+ //
361
+ // Keyed by sample_id, not run_id: every run of the same sample shares
362
+ // the rollup. Fetched once per sample and refreshed on the server's
363
+ // sample_summary_updated broadcast (fired when a sibling finishes or its
364
+ // exclusion is toggled). Empty unless the method declares aggregate
365
+ // results_fields.
366
+ // -----------------------------------------------------------------
367
+ const sampleId: string =
368
+ (typeof meta?.sample_id === 'string' && meta.sample_id)
369
+ ? meta.sample_id
370
+ : (typeof meta?.config?.sample_id === 'string' ? meta.config.sample_id : '');
371
+
372
+ useEffect(() => {
373
+ if (!projectId || !methodId || !sampleId) { setSampleSummary({}); return; }
374
+ let cancelled = false;
375
+ const fetchSummary = async () => {
376
+ try {
377
+ const resp: any = await invoke(
378
+ 'tis.sample_summary' as any, MessageType.Request as any,
379
+ { project_id: projectId, method_id: methodId, sample_id: sampleId } as any);
380
+ if (!cancelled && resp?.success) setSampleSummary(resp.data?.summary ?? {});
381
+ } catch (e) {
382
+ console.error('[TestDataView] sample_summary fetch failed', e);
383
+ }
384
+ };
385
+ fetchSummary();
386
+ const onSummary = (payload: any) => {
387
+ if (payload?.project_id === projectId
388
+ && payload?.method_id === methodId
389
+ && payload?.sample_id === sampleId) {
390
+ setSampleSummary(payload.summary ?? {});
391
+ }
392
+ };
393
+ const id = subscribe('tis.sample_summary_updated', onSummary);
394
+ return () => { cancelled = true; unsubscribe(id); };
395
+ }, [projectId, methodId, sampleId, invoke, subscribe, unsubscribe]);
396
+
354
397
  // -----------------------------------------------------------------
355
398
  // Raw-trace data fetch (lazy)
356
399
  //
@@ -721,7 +764,10 @@ export const TestDataView: React.FC<TestDataViewProps> = (props) => {
721
764
 
722
765
  <div className="p-card" style={{ padding: '1rem' }}>
723
766
  <h3 style={{ marginTop: 0 }}>Results</h3>
724
- <ResultsGrid schema={schema.results_fields} values={results} />
767
+ {/* Overlay the server-derived cross-test rollup (avg/min/max/
768
+ stddev/count) onto this run's own results. Aggregate fields
769
+ are never in `results` — the summary is their only source. */}
770
+ <ResultsGrid schema={schema.results_fields} values={{ ...results, ...sampleSummary }} />
725
771
  </div>
726
772
 
727
773
  {/*
@@ -60,6 +60,11 @@ export interface TestFieldDef {
60
60
  * Cycle and results values are scaled by the corresponding paths
61
61
  * in TestDataView; the server scales CSV exports too. */
62
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;
63
68
  /** Optional fixed set of choices. When present, the field renders
64
69
  * as a dropdown and the operator must pick one of the declared
65
70
  * values rather than typing freely. Each entry is either a bare
@@ -186,6 +191,62 @@ const displayToRaw = (display: any, scale: number | undefined): any => {
186
191
  const hasDescription = (f: TestFieldDef): boolean =>
187
192
  typeof f.description === 'string' && f.description.length > 0;
188
193
 
194
+ /** Matches an FQDN default token like `${gm.safe_speed}` (whole-string). */
195
+ const DEFAULT_TOKEN_RE = /^\$\{\s*([^}]+?)\s*\}$/;
196
+
197
+ /**
198
+ * Resolve a field's `default` to a RAW value for seeding stagedConfig at
199
+ * method-load time. Two forms:
200
+ *
201
+ * - **Literal** (number/string/bool) — authored in DISPLAY units, converted
202
+ * to raw via the field's `scale` (the long-standing convention).
203
+ * - **FQDN token** `${<fqdn>}` — snapshots the *current* value of that tag
204
+ * (read live from the controller, already RAW). Lets an author tie a
205
+ * config_field's load-time default to a value on screen — e.g. a "safe
206
+ * speed" the operator set — so re-loading the method always re-seeds the
207
+ * safe value, while still letting the operator override it afterwards.
208
+ * Distinct from `source`, which is a live two-way binding; a token default
209
+ * is a one-time seed.
210
+ *
211
+ * Returns `{ raw, ok }`. `ok === false` means an `${fqdn}` token whose tag is
212
+ * unknown or has no value yet — the caller skips seeding that field rather
213
+ * than writing garbage.
214
+ */
215
+ const resolveDefaultRaw = (
216
+ field: TestFieldDef,
217
+ findTagByFqdn: (fqdn: string) => { tagName: string } | undefined,
218
+ rawValues: Record<string, unknown>,
219
+ ): { raw: any; ok: boolean } => {
220
+ const d = field.default;
221
+ if (typeof d === 'string') {
222
+ const m = d.match(DEFAULT_TOKEN_RE);
223
+ if (m) {
224
+ const tag = findTagByFqdn(m[1]);
225
+ const v = tag ? rawValues[tag.tagName] : undefined;
226
+ if (v === undefined || v === null) return { raw: undefined, ok: false };
227
+ return { raw: v, ok: true }; // tag value is already RAW
228
+ }
229
+ }
230
+ return { raw: displayToRaw(d, field.scale), ok: true };
231
+ };
232
+
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
+ if (f.min != null && disp < f.min) return `must be ≥ ${f.min}`;
246
+ if (f.max != null && disp > f.max) return `must be ≤ ${f.max}`;
247
+ return null;
248
+ };
249
+
189
250
  /**
190
251
  * Normalise a field's `options` (bare scalars and/or `{label, value}`
191
252
  * pairs) into the `{ label, value }[]` shape PrimeReact's Dropdown
@@ -401,13 +462,31 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
401
462
  for (const field of schema.config_fields) {
402
463
  if (field.name === 'sample_id') continue;
403
464
  if (field.default === undefined || field.default === null) continue;
465
+ // A source-bound field's default must only SEED when GM has no
466
+ // value yet — never clobber an existing one. The defaults effect
467
+ // re-runs after every completed run (clearStagedConfig resets the
468
+ // marker), so without this guard the operator's setting (e.g.
469
+ // cof_window_pct) is reset to the literal default on each load.
470
+ // When GM already holds a value, keep it; the source-seed effect
471
+ // below mirrors the live value into stagedConfig.
472
+ if (field.source) {
473
+ const liveTag = findTagByFqdn(field.source);
474
+ const liveVal = liveTag ? rawValues[liveTag.tagName] : undefined;
475
+ if (liveVal !== undefined && liveVal !== null) continue;
476
+ }
477
+ // Resolve the default to a RAW value. Literals are authored in
478
+ // DISPLAY units (converted via `scale`); an `${fqdn}` token
479
+ // snapshots that tag's current (already-raw) value so a
480
+ // load-time default can track a value on screen. A token that
481
+ // can't resolve (tag unknown / not yet read) leaves the field
482
+ // unset rather than seeding garbage.
483
+ const { raw: rawDefault, ok } = resolveDefaultRaw(field, findTagByFqdn, rawValues);
484
+ if (!ok) {
485
+ console.warn(
486
+ `[TestSetupForm] default token ${String(field.default)} for "${field.name}" did not resolve; leaving it unset.`);
487
+ continue;
488
+ }
404
489
  if (next === prev) next = { ...prev };
405
- // Schema defaults are authored in DISPLAY units (per
406
- // the agreed convention) so the value the author reads
407
- // in project.json matches the field's `units` label.
408
- // Convert to raw before storing in stagedConfig / GM
409
- // so the rest of the pipeline sees the canonical value.
410
- const rawDefault = displayToRaw(field.default, field.scale);
411
490
  next[field.name] = rawDefault;
412
491
  if (field.source) {
413
492
  // Mirror handleFieldChange: write to GM so the
@@ -430,7 +509,7 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
430
509
  });
431
510
 
432
511
  tis.setConfigurationName(firstConfig ? firstConfig.name : '');
433
- }, [schema, methodId, write, tis.defaultsAppliedForMethod, tis.markDefaultsAppliedForMethod, tis.setConfigurationName]);
512
+ }, [schema, methodId, write, rawValues, findTagByFqdn, tis.defaultsAppliedForMethod, tis.markDefaultsAppliedForMethod, tis.setConfigurationName]);
434
513
 
435
514
  // Seed and live-update config_fields that declare a `source`.
436
515
  useEffect(() => {
@@ -467,10 +546,10 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
467
546
 
468
547
  for (const field of schema.config_fields) {
469
548
  if (field.name === 'sample_id') continue;
470
- if (field.required) {
471
- const v = config[field.name];
472
- if (v === undefined || v === '' || v === null) { valid = false; break; }
473
- }
549
+ const v = config[field.name];
550
+ const empty = v === undefined || v === '' || v === null;
551
+ if (field.required && empty) { valid = false; break; }
552
+ if (rangeIssue(field, v)) { valid = false; break; }
474
553
  }
475
554
 
476
555
  setIsValid(valid);
@@ -501,9 +580,11 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
501
580
  ]);
502
581
 
503
582
  const isFieldValid = (field: TestFieldDef) => {
504
- if (!field.required) return true;
505
583
  const v = config[field.name];
506
- return v !== undefined && v !== '' && v !== null;
584
+ const empty = v === undefined || v === '' || v === null;
585
+ if (field.required && empty) return false;
586
+ if (rangeIssue(field, v)) return false;
587
+ return true;
507
588
  };
508
589
 
509
590
  const handleSampleIdChange = (value: string) => {
@@ -595,6 +676,11 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
595
676
  value={config[field.name] != null
596
677
  ? Number(rawToDisplay(Number(config[field.name]), field.scale))
597
678
  : null}
679
+ // min/max are authored in display units, matching the
680
+ // value rendered above — ValueInput rejects out-of-range
681
+ // entries on accept.
682
+ min={field.min}
683
+ max={field.max}
598
684
  onValueChanged={(val) => handleFieldChange(field, val)}
599
685
  className={!valid ? 'p-invalid' : ''}
600
686
  />
@@ -682,11 +768,14 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
682
768
  if (schema) {
683
769
  for (const field of schema.config_fields) {
684
770
  if (field.name === 'sample_id') continue;
685
- if (!field.required) continue;
686
771
  const v = config[field.name];
687
- if (v === undefined || v === '' || v === null) {
772
+ const empty = v === undefined || v === '' || v === null;
773
+ if (field.required && empty) {
688
774
  issues.push(`Required field "${labelOf(field)}" is empty.`);
775
+ continue;
689
776
  }
777
+ const re = rangeIssue(field, v);
778
+ if (re) issues.push(`"${labelOf(field)}" ${re}.`);
690
779
  }
691
780
  }
692
781
  return issues;
@@ -17,7 +17,14 @@ const DESCRIPTIONS: Record<FieldArrayKey, string> = {
17
17
  project_fields: 'System-level fields (operator, station). Filled by the HMI but not cycled.',
18
18
  config_fields: 'Operator-input config (speeds, loads). Snapshotted into test.json on start.',
19
19
  cycle_fields: 'Per-cycle capture. One row appended to cycles.jsonl per cycle.',
20
- results_fields: 'Post-test summary (min/max/avg, pass/fail). Written once at finish.',
20
+ results_fields: 'Post-test summary (min/max/avg, pass/fail). Written once at finish. A field can also be a cross-test Aggregate (avg/min/max/stddev/count over tests sharing a sample_id).',
21
+ };
22
+
23
+ /** Short human label for a field's aggregate spec, e.g. "avg(cof)" / "count". */
24
+ const aggregateLabel = (f: TestField): string => {
25
+ const a = f.aggregate;
26
+ if (!a) return '';
27
+ return a.fn === 'count' ? 'count' : `${a.fn}(${a.of ?? '?'})`;
21
28
  };
22
29
 
23
30
  export interface FieldArrayEditorProps {
@@ -116,6 +123,13 @@ export const FieldArrayEditor: React.FC<FieldArrayEditorProps> = ({ arrayKey, me
116
123
  style={{ width: '4rem' }}
117
124
  />
118
125
  <Column field="label" header="Label" />
126
+ {arrayKey === 'results_fields' && (
127
+ <Column
128
+ header="Aggregate"
129
+ body={(r: TestField) => aggregateLabel(r)}
130
+ style={{ width: '8rem' }}
131
+ />
132
+ )}
119
133
  <Column header="" body={rowActions} style={{ width: '10rem' }} />
120
134
  </DataTable>
121
135
  </FormSection>
@@ -123,6 +137,9 @@ export const FieldArrayEditor: React.FC<FieldArrayEditorProps> = ({ arrayKey, me
123
137
  visible={dialogOpen}
124
138
  initial={editingIdx !== null ? (fields[editingIdx] ?? null) : null}
125
139
  siblingNames={fields.map(f => f.name)}
140
+ isResultsField={arrayKey === 'results_fields'}
141
+ cycleFieldNames={(method.cycle_fields ?? []).map(f => f.name).filter(Boolean)}
142
+ resultsFieldNames={(method.results_fields ?? []).map(f => f.name).filter(Boolean)}
126
143
  onCancel={() => setDialogOpen(false)}
127
144
  onSave={handleSaveField}
128
145
  />