@adcops/autocore-react 3.3.106 → 3.3.111

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 (68) hide show
  1. package/dist/assets/JogXLeft.d.ts +4 -0
  2. package/dist/assets/JogXLeft.d.ts.map +1 -0
  3. package/dist/assets/JogXLeft.js +1 -0
  4. package/dist/assets/JogXRight.d.ts +4 -0
  5. package/dist/assets/JogXRight.d.ts.map +1 -0
  6. package/dist/assets/JogXRight.js +1 -0
  7. package/dist/assets/index.d.ts +2 -2
  8. package/dist/assets/index.d.ts.map +1 -1
  9. package/dist/assets/index.js +1 -1
  10. package/dist/components/JogPanel.d.ts +29 -4
  11. package/dist/components/JogPanel.d.ts.map +1 -1
  12. package/dist/components/JogPanel.js +1 -1
  13. package/dist/components/ValueInput.d.ts.map +1 -1
  14. package/dist/components/ValueInput.js +1 -1
  15. package/dist/components/index.d.ts +2 -0
  16. package/dist/components/index.d.ts.map +1 -1
  17. package/dist/components/index.js +1 -1
  18. package/dist/components/network/StagedChangeBanner.d.ts.map +1 -1
  19. package/dist/components/network/StagedChangeBanner.js +1 -1
  20. package/dist/components/tis/ResultHistoryTable.css +12 -0
  21. package/dist/components/tis/ResultHistoryTable.d.ts +1 -0
  22. package/dist/components/tis/ResultHistoryTable.d.ts.map +1 -1
  23. package/dist/components/tis/ResultHistoryTable.js +1 -1
  24. package/dist/components/tis/SampleSummaryPanel.d.ts +23 -0
  25. package/dist/components/tis/SampleSummaryPanel.d.ts.map +1 -0
  26. package/dist/components/tis/SampleSummaryPanel.js +1 -0
  27. package/dist/components/tis/ScienceTable.css +87 -0
  28. package/dist/components/tis/ScienceTable.d.ts +35 -0
  29. package/dist/components/tis/ScienceTable.d.ts.map +1 -0
  30. package/dist/components/tis/ScienceTable.js +1 -0
  31. package/dist/components/tis/TestDataView.d.ts.map +1 -1
  32. package/dist/components/tis/TestDataView.js +1 -1
  33. package/dist/components/tis/TestSetupForm.d.ts.map +1 -1
  34. package/dist/components/tis/TestSetupForm.js +1 -1
  35. package/dist/components/tis-editor/editor/FieldArrayEditor.d.ts.map +1 -1
  36. package/dist/components/tis-editor/editor/FieldArrayEditor.js +1 -1
  37. package/dist/components/tis-editor/editor/TestFieldDialog.d.ts +6 -0
  38. package/dist/components/tis-editor/editor/TestFieldDialog.d.ts.map +1 -1
  39. package/dist/components/tis-editor/editor/TestFieldDialog.js +1 -1
  40. package/dist/components/tis-editor/types.d.ts +15 -0
  41. package/dist/components/tis-editor/types.d.ts.map +1 -1
  42. package/dist/components/tis-editor/validation.d.ts.map +1 -1
  43. package/dist/components/tis-editor/validation.js +1 -1
  44. package/package.json +1 -1
  45. package/src/assets/{JogXNeg.tsx → JogXLeft.tsx} +6 -3
  46. package/src/assets/{JogXPos.tsx → JogXRight.tsx} +6 -3
  47. package/src/assets/index.ts +2 -2
  48. package/src/components/JogPanel.tsx +64 -59
  49. package/src/components/ValueInput.tsx +10 -3
  50. package/src/components/index.ts +3 -0
  51. package/src/components/network/StagedChangeBanner.tsx +22 -3
  52. package/src/components/tis/ResultHistoryTable.css +12 -0
  53. package/src/components/tis/ResultHistoryTable.tsx +76 -4
  54. package/src/components/tis/SampleSummaryPanel.tsx +171 -0
  55. package/src/components/tis/ScienceTable.css +87 -0
  56. package/src/components/tis/ScienceTable.tsx +114 -0
  57. package/src/components/tis/TestDataView.tsx +92 -51
  58. package/src/components/tis/TestSetupForm.tsx +18 -2
  59. package/src/components/tis-editor/editor/FieldArrayEditor.tsx +18 -1
  60. package/src/components/tis-editor/editor/TestFieldDialog.tsx +82 -1
  61. package/src/components/tis-editor/types.ts +17 -0
  62. package/src/components/tis-editor/validation.ts +35 -0
  63. package/dist/assets/JogXNeg.d.ts +0 -4
  64. package/dist/assets/JogXNeg.d.ts.map +0 -1
  65. package/dist/assets/JogXNeg.js +0 -1
  66. package/dist/assets/JogXPos.d.ts +0 -4
  67. package/dist/assets/JogXPos.d.ts.map +0 -1
  68. package/dist/assets/JogXPos.js +0 -1
@@ -44,6 +44,8 @@ import { EventEmitterContext } from '../../core/EventEmitterContext';
44
44
  import { MessageType } from '../../hub/CommandMessage';
45
45
  import { useTis } from './TisProvider';
46
46
  import { useRawCycleData } from './useRawCycleData';
47
+ import { ScienceTable } from './ScienceTable';
48
+ import type { ScienceColumn } from './ScienceTable';
47
49
 
48
50
  ChartJS.register(
49
51
  CategoryScale, LinearScale, PointElement, LineElement,
@@ -199,6 +201,10 @@ export const TestDataView: React.FC<TestDataViewProps> = (props) => {
199
201
  const [meta, setMeta] = useState<any>(null);
200
202
  const [cycles, setCycles] = useState<any[]>([]);
201
203
  const [results, setResults] = useState<any>({});
204
+ // Server-derived cross-test aggregate results_fields (avg/min/max/stddev/
205
+ // count over the sample group). Overlaid on `results` for display — these
206
+ // fields are never in the per-test results blob.
207
+ const [sampleSummary, setSampleSummary] = useState<any>({});
202
208
  const [rawOpen, setRawOpen] = useState(false);
203
209
  const [configOpen, setConfigOpen] = useState(false);
204
210
 
@@ -351,6 +357,45 @@ export const TestDataView: React.FC<TestDataViewProps> = (props) => {
351
357
  // eslint-disable-next-line react-hooks/exhaustive-deps
352
358
  }, [projectId, methodId, runId, throttleMs]);
353
359
 
360
+ // -----------------------------------------------------------------
361
+ // Cross-test aggregate summary (sample-group rollup)
362
+ //
363
+ // Keyed by sample_id, not run_id: every run of the same sample shares
364
+ // the rollup. Fetched once per sample and refreshed on the server's
365
+ // sample_summary_updated broadcast (fired when a sibling finishes or its
366
+ // exclusion is toggled). Empty unless the method declares aggregate
367
+ // results_fields.
368
+ // -----------------------------------------------------------------
369
+ const sampleId: string =
370
+ (typeof meta?.sample_id === 'string' && meta.sample_id)
371
+ ? meta.sample_id
372
+ : (typeof meta?.config?.sample_id === 'string' ? meta.config.sample_id : '');
373
+
374
+ useEffect(() => {
375
+ if (!projectId || !methodId || !sampleId) { setSampleSummary({}); return; }
376
+ let cancelled = false;
377
+ const fetchSummary = async () => {
378
+ try {
379
+ const resp: any = await invoke(
380
+ 'tis.sample_summary' as any, MessageType.Request as any,
381
+ { project_id: projectId, method_id: methodId, sample_id: sampleId } as any);
382
+ if (!cancelled && resp?.success) setSampleSummary(resp.data?.summary ?? {});
383
+ } catch (e) {
384
+ console.error('[TestDataView] sample_summary fetch failed', e);
385
+ }
386
+ };
387
+ fetchSummary();
388
+ const onSummary = (payload: any) => {
389
+ if (payload?.project_id === projectId
390
+ && payload?.method_id === methodId
391
+ && payload?.sample_id === sampleId) {
392
+ setSampleSummary(payload.summary ?? {});
393
+ }
394
+ };
395
+ const id = subscribe('tis.sample_summary_updated', onSummary);
396
+ return () => { cancelled = true; unsubscribe(id); };
397
+ }, [projectId, methodId, sampleId, invoke, subscribe, unsubscribe]);
398
+
354
399
  // -----------------------------------------------------------------
355
400
  // Raw-trace data fetch (lazy)
356
401
  //
@@ -690,39 +735,53 @@ export const TestDataView: React.FC<TestDataViewProps> = (props) => {
690
735
  </div>
691
736
  </div>
692
737
 
693
- <div className="p-card" style={{ padding: '1rem' }}>
694
- <h3 style={{ marginTop: 0 }}>Cycle Data ({cycles.length})</h3>
695
- {/* Size-to-content for small runs (≤ CYCLE_VIRTUAL_THRESHOLD
696
- rows) so the Results panel below isn't pushed off-screen
697
- on tests with one or two cycles. Larger runs flip to
698
- virtual scrolling against `cycleTableHeight` so a
699
- 1000-cycle test doesn't render 1000 row elements at
700
- once. Operators on test methods that always run a
701
- fixed-size short cycle list see no scrollbar at all. */}
702
- {(() => {
703
- const useVirtual = cycles.length > CYCLE_VIRTUAL_THRESHOLD;
704
- return (
705
- <DataTable
706
- value={cycles}
707
- scrollable={useVirtual}
708
- scrollHeight={useVirtual ? cycleTableHeight : undefined}
709
- virtualScrollerOptions={useVirtual ? { itemSize: 38 } : undefined}
710
- emptyMessage="No cycles yet."
711
- >
712
- {schema.cycle_fields.map(f => (
713
- <Column key={f.name} field={f.name}
714
- header={f.units ? `${f.name} (${f.units})` : f.name}
715
- body={(row) => formatCell(row[f.name], f.type, f.scale)} />
716
- ))}
717
- </DataTable>
718
- );
719
- })()}
720
- </div>
721
-
722
- <div className="p-card" style={{ padding: '1rem' }}>
723
- <h3 style={{ marginTop: 0 }}>Results</h3>
724
- <ResultsGrid schema={schema.results_fields} values={results} />
725
- </div>
738
+ {/* Size-to-content for small runs (≤ CYCLE_VIRTUAL_THRESHOLD
739
+ rows) so the Results panel below isn't pushed off-screen on
740
+ tests with one or two cycles. Larger runs flip to virtual
741
+ scrolling against `cycleTableHeight` so a 1000-cycle test
742
+ doesn't render 1000 row elements at once. */}
743
+ {(() => {
744
+ const useVirtual = cycles.length > CYCLE_VIRTUAL_THRESHOLD;
745
+ const cycleColumns: ScienceColumn[] = schema.cycle_fields.map(f => ({
746
+ field: f.name,
747
+ label: f.name,
748
+ units: f.units,
749
+ body: (row) => formatCell(row[f.name], f.type, f.scale),
750
+ }));
751
+ return (
752
+ <ScienceTable
753
+ title={`Cycle Data (${cycles.length})`}
754
+ columns={cycleColumns}
755
+ rows={cycles}
756
+ scrollable={useVirtual}
757
+ scrollHeight={useVirtual ? cycleTableHeight : undefined}
758
+ emptyMessage="No cycles yet."
759
+ />
760
+ );
761
+ })()}
762
+
763
+ {/* Overlay the server-derived cross-test rollup (avg/min/max/
764
+ stddev/count) onto this run's own results. Aggregate fields
765
+ are never in `results` — the summary is their only source.
766
+ Rendered as a single-row report table (Bluehill style). */}
767
+ {(() => {
768
+ const values = { ...results, ...sampleSummary };
769
+ const hasValues = Object.keys(values).length > 0;
770
+ const resultColumns: ScienceColumn[] = schema.results_fields.map(f => ({
771
+ field: f.name,
772
+ label: f.name,
773
+ units: f.units,
774
+ body: (row) => formatCell(row[f.name], f.type, f.scale),
775
+ }));
776
+ return (
777
+ <ScienceTable
778
+ title="Results"
779
+ columns={resultColumns}
780
+ rows={hasValues ? [values] : []}
781
+ emptyMessage="No results yet."
782
+ />
783
+ );
784
+ })()}
726
785
 
727
786
  {/*
728
787
  * View Raw Data dialog — tabular listing of the raw and
@@ -1088,24 +1147,6 @@ const formatNumeric = (v: any): string => {
1088
1147
  return String(v);
1089
1148
  };
1090
1149
 
1091
- const ResultsGrid: React.FC<{ schema: TestFieldDef[]; values: any }> = ({ schema, values }) => {
1092
- if (!values || Object.keys(values).length === 0) {
1093
- return <div style={{ color: 'var(--text-secondary-color)' }}>No results yet.</div>;
1094
- }
1095
- return (
1096
- <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', gap: '0.5rem 1rem' }}>
1097
- {schema.map(f => (
1098
- <div key={f.name}>
1099
- <div style={{ fontSize: '0.8em', color: 'var(--text-secondary-color)' }}>
1100
- {f.name}{f.units ? ` (${f.units})` : ''}
1101
- </div>
1102
- <div>{formatCell(values[f.name], f.type, f.scale)}</div>
1103
- </div>
1104
- ))}
1105
- </div>
1106
- );
1107
- };
1108
-
1109
1150
  /** Row count above which the Cycle Data table switches into virtual-
1110
1151
  * scroll mode with a fixed `cycleTableHeight`. At or below the
1111
1152
  * threshold the table sizes to its content so the Results panel
@@ -242,8 +242,12 @@ const rangeIssue = (f: TestFieldDef, raw: any): string | null => {
242
242
  if (f.min == null && f.max == null) return null;
243
243
  const disp = Number(rawToDisplay(Number(raw), f.scale));
244
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}`;
245
+ // Bounds form an unordered interval an author may write the range in
246
+ // magnitude order (min:-50, max:-1000), which is numerically inverted.
247
+ const lo = f.min != null && f.max != null ? Math.min(f.min, f.max) : f.min;
248
+ const hi = f.min != null && f.max != null ? Math.max(f.min, f.max) : f.max;
249
+ if (lo != null && disp < lo) return `must be ≥ ${lo}`;
250
+ if (hi != null && disp > hi) return `must be ≤ ${hi}`;
247
251
  return null;
248
252
  };
249
253
 
@@ -462,6 +466,18 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
462
466
  for (const field of schema.config_fields) {
463
467
  if (field.name === 'sample_id') continue;
464
468
  if (field.default === undefined || field.default === null) continue;
469
+ // A source-bound field's default must only SEED when GM has no
470
+ // value yet — never clobber an existing one. The defaults effect
471
+ // re-runs after every completed run (clearStagedConfig resets the
472
+ // marker), so without this guard the operator's setting (e.g.
473
+ // cof_window_pct) is reset to the literal default on each load.
474
+ // When GM already holds a value, keep it; the source-seed effect
475
+ // below mirrors the live value into stagedConfig.
476
+ if (field.source) {
477
+ const liveTag = findTagByFqdn(field.source);
478
+ const liveVal = liveTag ? rawValues[liveTag.tagName] : undefined;
479
+ if (liveVal !== undefined && liveVal !== null) continue;
480
+ }
465
481
  // Resolve the default to a RAW value. Literals are authored in
466
482
  // DISPLAY units (converted via `scale`); an `${fqdn}` token
467
483
  // snapshots that tag's current (already-raw) value so a
@@ -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
  />
@@ -7,7 +7,7 @@ import { Dropdown } from 'primereact/dropdown';
7
7
  import { Checkbox } from 'primereact/checkbox';
8
8
  import { InputNumber } from 'primereact/inputnumber';
9
9
  import { FormRow } from '../../forms/FormRow';
10
- import type { TestField } from '../types';
10
+ import type { TestField, AggFn, AggFrom, AggregateSpec } from '../types';
11
11
 
12
12
  const FIELD_TYPES: { label: string; value: string }[] = [
13
13
  { label: 'string', value: 'string' },
@@ -20,6 +20,21 @@ const FIELD_TYPES: { label: string; value: string }[] = [
20
20
  { label: 'bool', value: 'bool' },
21
21
  ];
22
22
 
23
+ // '' = no aggregation (the field is supplied by the analysis script).
24
+ const AGG_FNS: { label: string; value: '' | AggFn }[] = [
25
+ { label: '— none (analysis-fed) —', value: '' },
26
+ { label: 'avg', value: 'avg' },
27
+ { label: 'min', value: 'min' },
28
+ { label: 'max', value: 'max' },
29
+ { label: 'stddev', value: 'stddev' },
30
+ { label: 'count (# included tests)', value: 'count' },
31
+ ];
32
+
33
+ const AGG_FROMS: { label: string; value: AggFrom }[] = [
34
+ { label: 'cycle field (every cycle, every test)', value: 'cycle_fields' },
35
+ { label: 'result (one value per test)', value: 'results' },
36
+ ];
37
+
23
38
  export interface TestFieldDialogProps {
24
39
  visible: boolean;
25
40
  initial: TestField | null;
@@ -27,12 +42,19 @@ export interface TestFieldDialogProps {
27
42
  onSave: (field: TestField) => void;
28
43
  /** Names already in use within the same array — for uniqueness validation. */
29
44
  siblingNames: string[];
45
+ /** True when this field belongs to `results_fields` — only then is the
46
+ * cross-test Aggregate section shown. */
47
+ isResultsField?: boolean;
48
+ /** Candidate target field names for an aggregate's `of`, by source. */
49
+ cycleFieldNames?: string[];
50
+ resultsFieldNames?: string[];
30
51
  }
31
52
 
32
53
  const blank: TestField = { name: '', type: 'f32' };
33
54
 
34
55
  export const TestFieldDialog: React.FC<TestFieldDialogProps> = ({
35
56
  visible, initial, onCancel, onSave, siblingNames,
57
+ isResultsField = false, cycleFieldNames = [], resultsFieldNames = [],
36
58
  }) => {
37
59
  const [draft, setDraft] = useState<TestField>(blank);
38
60
  // `default` is edited as free text so a numeric literal, a string, or an
@@ -73,9 +95,29 @@ export const TestFieldDialog: React.FC<TestFieldDialogProps> = ({
73
95
  if (typeof f.min === 'number' && typeof f.max === 'number' && f.min > f.max) {
74
96
  return `Min (${f.min}) cannot be greater than Max (${f.max}).`;
75
97
  }
98
+ if (f.aggregate && f.aggregate.fn !== 'count' && !f.aggregate.of?.trim()) {
99
+ return `Aggregate "${f.aggregate.fn}" needs a target field (the field to ${f.aggregate.fn} across tests).`;
100
+ }
76
101
  return null;
77
102
  };
78
103
 
104
+ // Build the aggregate spec from the chosen function. '' clears it; count
105
+ // drops of/from (it reduces over the set of included tests, not a field).
106
+ const setAggFn = (fn: '' | AggFn) => {
107
+ if (fn === '') { setDraft({ ...draft, aggregate: undefined }); return; }
108
+ if (fn === 'count') { setDraft({ ...draft, aggregate: { fn: 'count' } }); return; }
109
+ const next: AggregateSpec = { fn, of: draft.aggregate?.of, from: draft.aggregate?.from ?? 'cycle_fields' };
110
+ setDraft({ ...draft, aggregate: next });
111
+ };
112
+
113
+ const agg = draft.aggregate;
114
+ const aggFrom: AggFrom = agg?.from ?? 'cycle_fields';
115
+ // `of` candidates depend on the source; exclude this field's own name so
116
+ // a result can't aggregate itself.
117
+ const aggTargets = (aggFrom === 'results' ? resultsFieldNames : cycleFieldNames)
118
+ .filter(n => n && n !== draft.name)
119
+ .map(n => ({ label: n, value: n }));
120
+
79
121
  const handleSave = () => {
80
122
  const candidate: TestField = { ...draft };
81
123
  const dv = coerceDefault(defaultText);
@@ -173,6 +215,45 @@ export const TestFieldDialog: React.FC<TestFieldDialogProps> = ({
173
215
  placeholder="e.g. 5 or ${gm.safe_speed}"
174
216
  />
175
217
  </FormRow>
218
+ {isResultsField && (
219
+ <>
220
+ <div style={{ borderTop: '1px solid #e5e7eb', margin: '0.75rem 0 0.25rem' }} />
221
+ <FormRow
222
+ label="Aggregate"
223
+ hint="Cross-test rollup. When set, this result is derived server-side over the non-excluded tests sharing the same sample_id — the analysis script does not supply it."
224
+ >
225
+ <Dropdown
226
+ value={agg?.fn ?? ''}
227
+ options={AGG_FNS}
228
+ onChange={(e) => setAggFn(e.value)}
229
+ />
230
+ </FormRow>
231
+ {agg && agg.fn !== 'count' && (
232
+ <>
233
+ <FormRow label="Aggregate from" hint="Which per-test source the target field is read from.">
234
+ <Dropdown
235
+ value={aggFrom}
236
+ options={AGG_FROMS}
237
+ onChange={(e) =>
238
+ setDraft({ ...draft, aggregate: { ...agg, from: e.value } })
239
+ }
240
+ />
241
+ </FormRow>
242
+ <FormRow label="Aggregate of" required hint="The field to reduce across tests.">
243
+ <Dropdown
244
+ value={agg.of ?? null}
245
+ options={aggTargets}
246
+ onChange={(e) =>
247
+ setDraft({ ...draft, aggregate: { ...agg, of: e.value || undefined } })
248
+ }
249
+ editable
250
+ placeholder={aggTargets.length ? 'Select a field' : `No ${aggFrom} defined yet`}
251
+ />
252
+ </FormRow>
253
+ </>
254
+ )}
255
+ </>
256
+ )}
176
257
  <FormRow label="Description" hint="Hover tooltip in the form.">
177
258
  <InputTextarea
178
259
  rows={2}
@@ -27,6 +27,23 @@ export interface TestField {
27
27
  * numeric input rejects values outside `[min, max]`. */
28
28
  min?: number;
29
29
  max?: number;
30
+ /** results_fields only: a cross-test rollup derived server-side over the
31
+ * non-excluded runs sharing this test's sample_id. When set, the field
32
+ * is NOT supplied by the analysis script — the server computes it. */
33
+ aggregate?: AggregateSpec;
34
+ }
35
+
36
+ export type AggFn = 'avg' | 'min' | 'max' | 'stddev' | 'count';
37
+ export type AggFrom = 'cycle_fields' | 'results';
38
+
39
+ export interface AggregateSpec {
40
+ /** Reduction applied across the gathered values. */
41
+ fn: AggFn;
42
+ /** Field to aggregate — a cycle_field or results_field name (per `from`).
43
+ * Ignored for `fn: "count"` (which counts included tests). */
44
+ of?: string;
45
+ /** Which per-test source `of` is read from. Defaults to "cycle_fields". */
46
+ from?: AggFrom;
30
47
  }
31
48
 
32
49
  export interface ChartAxis {
@@ -39,6 +39,41 @@ export function validateMethod(methodId: string, m: TestMethod): ValidationError
39
39
  });
40
40
  }
41
41
 
42
+ // Aggregate specs: only on results_fields; target must exist in the
43
+ // chosen source array and be numeric. `count` needs no target. Mirrors
44
+ // tis_servelet::validate_method.
45
+ const NUMERIC_TYPES = new Set(['i32', 'i64', 'u32', 'u64', 'f32', 'f64']);
46
+ const cycleTypes = new Map<string, string>(
47
+ ((m.cycle_fields as TestField[] | undefined) ?? []).map((f) => [f.name, String(f.type)])
48
+ );
49
+ const resultTypes = new Map<string, string>(
50
+ ((m.results_fields as TestField[] | undefined) ?? []).map((f) => [f.name, String(f.type)])
51
+ );
52
+ for (const key of ['project_fields', 'config_fields', 'cycle_fields'] as const) {
53
+ ((m[key] as TestField[] | undefined) ?? []).forEach((f, i) => {
54
+ if (f.aggregate) {
55
+ errs.push({ path: `${key}.${i}.aggregate`, message: `${methodId}.${key}.${f.name}: aggregate is only valid on results_fields` });
56
+ }
57
+ });
58
+ }
59
+ ((m.results_fields as TestField[] | undefined) ?? []).forEach((f, i) => {
60
+ const a = f.aggregate;
61
+ if (!a || a.fn === 'count') return;
62
+ const of = a.of?.trim();
63
+ if (!of) {
64
+ errs.push({ path: `results_fields.${i}.aggregate`, message: `${methodId}.results_fields.${f.name}: aggregate "${a.fn}" requires a target field (of)` });
65
+ return;
66
+ }
67
+ const lookup = (a.from ?? 'cycle_fields') === 'results' ? resultTypes : cycleTypes;
68
+ const srcLabel = (a.from ?? 'cycle_fields') === 'results' ? 'results_fields' : 'cycle_fields';
69
+ const ty = lookup.get(of);
70
+ if (ty === undefined) {
71
+ errs.push({ path: `results_fields.${i}.aggregate`, message: `${methodId}.results_fields.${f.name}: aggregate "of" references "${of}" which is not a ${srcLabel} of this method` });
72
+ } else if (!NUMERIC_TYPES.has(ty)) {
73
+ errs.push({ path: `results_fields.${i}.aggregate`, message: `${methodId}.results_fields.${f.name}: aggregate target "${of}" has non-numeric type "${ty}"` });
74
+ }
75
+ });
76
+
42
77
  // Build sets for axis-reference resolution.
43
78
  const knownFields = new Set<string>();
44
79
  for (const key of FIELD_ARRAY_KEYS) {
@@ -1,4 +0,0 @@
1
- import type { SVGProps } from "react";
2
- export declare const SvgJogXNeg: (props: SVGProps<SVGSVGElement>) => import("react/jsx-runtime").JSX.Element;
3
- export default SvgJogXNeg;
4
- //# sourceMappingURL=JogXNeg.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"JogXNeg.d.ts","sourceRoot":"","sources":["../../src/assets/JogXNeg.tsx"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAEtC,eAAO,MAAM,UAAU,GAAI,OAAO,QAAQ,CAAC,aAAa,CAAC,4CAkBxD,CAAC;AAEF,eAAe,UAAU,CAAC"}
@@ -1 +0,0 @@
1
- import{jsx as _jsx,jsxs as _jsxs}from"react/jsx-runtime";export const SvgJogXNeg=o=>_jsxs("svg",{xmlns:"http://www.w3.org/2000/svg",width:"24",height:"24",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",...o,children:[_jsx("path",{d:"M10 6 3 12 10 18Z",fill:"currentColor"}),_jsx("path",{d:"M14 6l8 12M22 6l-8 12"})]});export default SvgJogXNeg;
@@ -1,4 +0,0 @@
1
- import type { SVGProps } from "react";
2
- export declare const SvgJogXPos: (props: SVGProps<SVGSVGElement>) => import("react/jsx-runtime").JSX.Element;
3
- export default SvgJogXPos;
4
- //# sourceMappingURL=JogXPos.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"JogXPos.d.ts","sourceRoot":"","sources":["../../src/assets/JogXPos.tsx"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AAEtC,eAAO,MAAM,UAAU,GAAI,OAAO,QAAQ,CAAC,aAAa,CAAC,4CAkBxD,CAAC;AAEF,eAAe,UAAU,CAAC"}
@@ -1 +0,0 @@
1
- import{jsx as _jsx,jsxs as _jsxs}from"react/jsx-runtime";export const SvgJogXPos=o=>_jsxs("svg",{xmlns:"http://www.w3.org/2000/svg",width:"24",height:"24",viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:"2",strokeLinecap:"round",strokeLinejoin:"round",...o,children:[_jsx("path",{d:"M2 6l8 12M10 6l-8 12"}),_jsx("path",{d:"M14 6 21 12 14 18Z",fill:"currentColor"})]});export default SvgJogXPos;