@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.
- package/dist/components/index.d.ts +2 -0
- package/dist/components/index.d.ts.map +1 -1
- package/dist/components/index.js +1 -1
- package/dist/components/tis/ResultHistoryTable.css +12 -0
- package/dist/components/tis/ResultHistoryTable.d.ts +1 -0
- package/dist/components/tis/ResultHistoryTable.d.ts.map +1 -1
- package/dist/components/tis/ResultHistoryTable.js +1 -1
- package/dist/components/tis/SampleSummaryPanel.d.ts +23 -0
- package/dist/components/tis/SampleSummaryPanel.d.ts.map +1 -0
- package/dist/components/tis/SampleSummaryPanel.js +1 -0
- package/dist/components/tis/TestDataView.d.ts.map +1 -1
- package/dist/components/tis/TestDataView.js +1 -1
- package/dist/components/tis/TestSetupForm.d.ts +5 -0
- package/dist/components/tis/TestSetupForm.d.ts.map +1 -1
- package/dist/components/tis/TestSetupForm.js +1 -1
- package/dist/components/tis-editor/editor/FieldArrayEditor.d.ts.map +1 -1
- package/dist/components/tis-editor/editor/FieldArrayEditor.js +1 -1
- package/dist/components/tis-editor/editor/TestFieldDialog.d.ts +6 -0
- package/dist/components/tis-editor/editor/TestFieldDialog.d.ts.map +1 -1
- package/dist/components/tis-editor/editor/TestFieldDialog.js +1 -1
- package/dist/components/tis-editor/types.d.ts +20 -0
- package/dist/components/tis-editor/types.d.ts.map +1 -1
- package/dist/components/tis-editor/validation.d.ts.map +1 -1
- package/dist/components/tis-editor/validation.js +1 -1
- package/package.json +1 -1
- package/src/components/index.ts +3 -0
- package/src/components/tis/ResultHistoryTable.css +12 -0
- package/src/components/tis/ResultHistoryTable.tsx +61 -0
- package/src/components/tis/SampleSummaryPanel.tsx +171 -0
- package/src/components/tis/TestDataView.tsx +47 -1
- package/src/components/tis/TestSetupForm.tsx +104 -15
- package/src/components/tis-editor/editor/FieldArrayEditor.tsx +18 -1
- package/src/components/tis-editor/editor/TestFieldDialog.tsx +139 -3
- package/src/components/tis-editor/types.ts +22 -0
- 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
|
-
|
|
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
|
-
|
|
471
|
-
|
|
472
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
/>
|