@adcops/autocore-react 3.3.83 → 3.3.84

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.
@@ -43,8 +43,22 @@ export interface TestFieldDef {
43
43
  * the control program sees it; for non-source fields it's stashed
44
44
  * straight into stagedConfig. Operator edits override per-stage,
45
45
  * but the schema default never mutates — re-selecting the method
46
- * re-applies the default. */
46
+ * re-applies the default. **Authored in display units** when
47
+ * `scale` is set — the form divides by `scale` before writing to
48
+ * GM / stagedConfig so the underlying storage stays raw. */
47
49
  default?: any;
50
+ /** Optional unit-conversion multiplier. Mirrors AutoCoreTagContext:
51
+ * ```
52
+ * display = raw * scale
53
+ * raw = display / scale
54
+ * ```
55
+ * Example: backend stores degrees, operator enters revolutions →
56
+ * `scale: 0.00277778`. None / 1.0 = no conversion.
57
+ *
58
+ * Storage stays raw — the form scales only on input and display.
59
+ * Cycle and results values are scaled by the corresponding paths
60
+ * in TestDataView; the server scales CSV exports too. */
61
+ scale?: number;
48
62
  }
49
63
 
50
64
  export interface TestMethod {
@@ -104,6 +118,30 @@ const labelOf = (f: TestFieldDef): string => {
104
118
  return f.units ? `${base} [${f.units}]` : base;
105
119
  };
106
120
 
121
+ /**
122
+ * Two helpers for the display ↔ raw boundary on numeric fields.
123
+ *
124
+ * Convention: `display = raw * scale`, `raw = display / scale`. Matches
125
+ * AutoCoreTagContext. Default scale = 1.0 (no conversion). Non-numeric
126
+ * input values (string, null, undefined, NaN) pass through unchanged
127
+ * — the form has fields of other types that share the change handler.
128
+ *
129
+ * Storage (config map, GM, on-disk JSON) always holds the raw value.
130
+ * Only the inputs the operator looks at and the cells in TestDataView
131
+ * use the display value. This way scales can change in project.json
132
+ * without invalidating historical records.
133
+ */
134
+ const rawToDisplay = (raw: any, scale: number | undefined): any => {
135
+ if (!scale || scale === 1) return raw;
136
+ if (typeof raw !== 'number' || !Number.isFinite(raw)) return raw;
137
+ return raw * scale;
138
+ };
139
+ const displayToRaw = (display: any, scale: number | undefined): any => {
140
+ if (!scale || scale === 1) return display;
141
+ if (typeof display !== 'number' || !Number.isFinite(display)) return display;
142
+ return display / scale;
143
+ };
144
+
107
145
  const hasDescription = (f: TestFieldDef): boolean =>
108
146
  typeof f.description === 'string' && f.description.length > 0;
109
147
 
@@ -265,14 +303,20 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
265
303
  if (field.name === 'sample_id') continue;
266
304
  if (field.default === undefined || field.default === null) continue;
267
305
  if (next === prev) next = { ...prev };
268
- next[field.name] = field.default;
306
+ // Schema defaults are authored in DISPLAY units (per
307
+ // the agreed convention) so the value the author reads
308
+ // in project.json matches the field's `units` label.
309
+ // Convert to raw before storing in stagedConfig / GM
310
+ // so the rest of the pipeline sees the canonical value.
311
+ const rawDefault = displayToRaw(field.default, field.scale);
312
+ next[field.name] = rawDefault;
269
313
  if (field.source) {
270
314
  // Mirror handleFieldChange: write to GM so the
271
315
  // control program sees the default. Errors here are
272
316
  // logged but non-fatal — the form still reflects
273
317
  // the default locally.
274
318
  void Promise.resolve()
275
- .then(() => write(field.source!, field.default))
319
+ .then(() => write(field.source!, rawDefault))
276
320
  .catch(e => console.error(
277
321
  `[TestSetupForm] Failed to seed default for ${field.name}:`, e));
278
322
  }
@@ -360,9 +404,13 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
360
404
  };
361
405
 
362
406
  const handleFieldChange = async (field: TestFieldDef, val: any) => {
363
- setConfig({ ...config, [field.name]: val });
407
+ // The operator typed a DISPLAY value; convert to RAW before
408
+ // storing or writing to GM. Non-numeric fields and identity
409
+ // scales pass through unchanged via the helper.
410
+ const rawVal = displayToRaw(val, field.scale);
411
+ setConfig({ ...config, [field.name]: rawVal });
364
412
  if (field.source) {
365
- try { await write(field.source, val); }
413
+ try { await write(field.source, rawVal); }
366
414
  catch (e) { console.error('Failed to write to source:', e); }
367
415
  }
368
416
  };
@@ -417,7 +465,13 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
417
465
  ) : isNum ? (
418
466
  <ValueInput
419
467
  label={undefined}
420
- value={config[field.name] != null ? Number(config[field.name]) : null}
468
+ // Storage is RAW; render the DISPLAY value so
469
+ // operator sees the units they configured. The
470
+ // change handler runs the inverse before
471
+ // committing back to storage.
472
+ value={config[field.name] != null
473
+ ? Number(rawToDisplay(Number(config[field.name]), field.scale))
474
+ : null}
421
475
  onValueChanged={(val) => handleFieldChange(field, val)}
422
476
  className={!valid ? 'p-invalid' : ''}
423
477
  />
@@ -11,12 +11,27 @@
11
11
  * (set `enabled` to false to short-circuit while
12
12
  * a cycle_scatter view is active).
13
13
  *
14
- * Keeping the fetch logic in one place means the two components can't
15
- * drift in how they discover cycles, default to "latest cycle", or
16
- * unwrap the per-cycle envelope.
14
+ * Live updates
15
+ * ------------
16
+ * The hook subscribes to the `tis.raw_data_added` broadcast and refreshes
17
+ * the cycle list whenever a new raw blob lands on disk for the matching
18
+ * (project, method, run, blob). When the operator is already viewing the
19
+ * latest cycle, the picker advances to the new latest and the chart
20
+ * re-paints — that's the customer-facing "chart updates each cycle"
21
+ * behaviour. When the operator has manually pinned an earlier cycle (e.g.
22
+ * examining cycle 3 while cycle 47 streams in), the picker stays put so
23
+ * the examination isn't interrupted.
24
+ *
25
+ * The hook intentionally suppresses `read_raw` until at least one cycle
26
+ * is known to be on disk for this slice. Otherwise the server returns
27
+ * "no raw_data file found" between `tis.start_test` and the first
28
+ * `add_raw_data` write, and the chart flashes an error overlay during
29
+ * what's really just "test just started, no data yet" — confusing for
30
+ * the operator. With the suppression the chart sits empty (or in a
31
+ * loading state) until the first raw_data_added broadcast arrives.
17
32
  */
18
33
 
19
- import { useContext, useEffect, useState } from 'react';
34
+ import { useCallback, useContext, useEffect, useRef, useState } from 'react';
20
35
  import { EventEmitterContext } from '../../core/EventEmitterContext';
21
36
  import { MessageType } from '../../hub/CommandMessage';
22
37
 
@@ -50,7 +65,7 @@ export interface UseRawCycleDataResult {
50
65
 
51
66
  export function useRawCycleData(opts: UseRawCycleDataOptions): UseRawCycleDataResult {
52
67
  const { projectId, methodId, runId, blobName, enabled } = opts;
53
- const { invoke } = useContext(EventEmitterContext);
68
+ const { invoke, subscribe, unsubscribe } = useContext(EventEmitterContext);
54
69
 
55
70
  const [cycles, setCycles] = useState<number[]>([]);
56
71
  const [selectedCycle, setSelectedCycle] = useState<number | null>(null);
@@ -59,14 +74,49 @@ export function useRawCycleData(opts: UseRawCycleDataOptions): UseRawCycleDataRe
59
74
  const [loading, setLoading] = useState(false);
60
75
  const [error, setError] = useState<string | null>(null);
61
76
 
77
+ // Track whether the user has explicitly pinned a cycle. When false,
78
+ // the live-follow path is free to advance the picker to the newest
79
+ // cycle as it arrives. Flipping to true on a manual pick freezes
80
+ // the picker so examining cycle 3 while cycle 47 streams in doesn't
81
+ // get yanked back to 47.
82
+ //
83
+ // Lives in a ref so updating it doesn't re-trigger any effect — it's
84
+ // a behavioural latch, not state the renderer cares about.
85
+ const userPinnedRef = useRef<boolean>(false);
86
+
87
+ /** Discover the per-cycle indices currently on disk for this slice.
88
+ * Returns the sorted-ascending list; never throws (a listing error
89
+ * is logged-as-empty rather than surfaced — the rendering layer
90
+ * has its own empty state). */
91
+ const listCycles = useCallback(async (): Promise<number[]> => {
92
+ if (!projectId || !methodId || !runId) return [];
93
+ try {
94
+ const resp: any = await invoke(
95
+ 'tis.list_raw' as any, MessageType.Request as any,
96
+ { project_id: projectId, method_id: methodId, run_id: runId } as any,
97
+ );
98
+ if (!resp?.success) return [];
99
+ const list: any[] = resp.data?.cycles ?? [];
100
+ return list
101
+ .filter(c => c?.name === blobName && typeof c?.cycle_index === 'number')
102
+ .map(c => c.cycle_index as number)
103
+ .sort((a, b) => a - b);
104
+ } catch {
105
+ return [];
106
+ }
107
+ }, [projectId, methodId, runId, blobName, invoke]);
108
+
62
109
  // Reset cycle state when the run identity (or blob) changes — the
63
110
  // new slice has its own cycle list and its own "latest" target.
111
+ // Clearing userPinnedRef too so the next slice is free to follow
112
+ // the latest cycle automatically.
64
113
  useEffect(() => {
65
114
  setCycles([]);
66
115
  setSelectedCycle(null);
67
116
  setRaw(null);
68
117
  setEnvelope(null);
69
118
  setError(null);
119
+ userPinnedRef.current = false;
70
120
  }, [projectId, methodId, runId, blobName]);
71
121
 
72
122
  // Cycle-list discovery. Runs ahead of the data fetch so the cycle
@@ -75,47 +125,86 @@ export function useRawCycleData(opts: UseRawCycleDataOptions): UseRawCycleDataRe
75
125
  if (!enabled || !projectId || !methodId || !runId) return;
76
126
  let cancelled = false;
77
127
  (async () => {
78
- try {
79
- const resp: any = await invoke(
80
- 'tis.list_raw' as any, MessageType.Request as any,
81
- { project_id: projectId, method_id: methodId, run_id: runId } as any,
82
- );
83
- if (cancelled || !resp?.success) return;
84
- const list: any[] = resp.data?.cycles ?? [];
85
- const indices = list
86
- .filter(c => c?.name === blobName && typeof c?.cycle_index === 'number')
87
- .map(c => c.cycle_index as number)
88
- .sort((a, b) => a - b);
89
- setCycles(indices);
90
- if (indices.length > 0) {
91
- setSelectedCycle(prev => prev ?? indices[indices.length - 1]);
92
- }
93
- } catch {
94
- // Listing failure is non-fatal — the data fetch below
95
- // still tries "latest" and the picker just stays empty.
128
+ const indices = await listCycles();
129
+ if (cancelled) return;
130
+ setCycles(indices);
131
+ if (indices.length > 0) {
132
+ setSelectedCycle(prev => prev ?? indices[indices.length - 1]);
96
133
  }
97
134
  })();
98
135
  return () => { cancelled = true; };
99
- }, [enabled, projectId, methodId, runId, blobName, invoke]);
136
+ }, [enabled, projectId, methodId, runId, blobName, listCycles]);
137
+
138
+ // Live updates: each new raw_data file landing on disk triggers a
139
+ // re-listing. The handler closes over `selectedCycle` via the
140
+ // functional setter and the live `cycles` snapshot, so we use refs
141
+ // to avoid re-subscribing every time those state values change.
142
+ const cyclesRef = useRef<number[]>(cycles);
143
+ cyclesRef.current = cycles;
144
+ const selectedRef = useRef<number | null>(selectedCycle);
145
+ selectedRef.current = selectedCycle;
146
+
147
+ useEffect(() => {
148
+ if (!enabled || !projectId || !methodId || !runId) return;
149
+ const onRawAdded = async (payload: any) => {
150
+ // Filter to the slice this hook instance is bound to.
151
+ // The server can also fire raw_data_added for the same
152
+ // run+method+project but a different blob name (rare, but
153
+ // legal); the blob-name check keeps us from refreshing
154
+ // for an unrelated dataset.
155
+ if (payload?.project_id !== projectId) return;
156
+ if (payload?.method_id !== methodId) return;
157
+ if (payload?.run_id !== runId) return;
158
+ if (payload?.name !== blobName) return;
159
+
160
+ const fresh = await listCycles();
161
+ if (fresh.length === 0) return;
162
+ setCycles(fresh);
163
+ // Auto-follow "latest" iff the operator hasn't manually
164
+ // moved away from it. The "was viewing the previous
165
+ // latest" check uses the snapshot taken via the ref so the
166
+ // closure stays stable.
167
+ const prevLatest = cyclesRef.current.length > 0
168
+ ? cyclesRef.current[cyclesRef.current.length - 1]
169
+ : null;
170
+ const newLatest = fresh[fresh.length - 1];
171
+ const onLatest = !userPinnedRef.current
172
+ && (selectedRef.current === null || selectedRef.current === prevLatest);
173
+ if (onLatest && newLatest !== selectedRef.current) {
174
+ setSelectedCycle(newLatest);
175
+ }
176
+ };
177
+ const id = subscribe('tis.raw_data_added', onRawAdded);
178
+ return () => { unsubscribe(id); };
179
+ }, [enabled, projectId, methodId, runId, blobName, listCycles, subscribe, unsubscribe]);
100
180
 
101
181
  // Lazy blob fetch — runs whenever identifiers / selectedCycle change.
182
+ // Suppressed entirely when no cycle is selected (e.g. test just
183
+ // started, no raw_data on disk yet): no point asking the server
184
+ // for a file that's known to not exist, and surfacing the resulting
185
+ // error would flash an unfriendly overlay over an otherwise valid
186
+ // "waiting for data" state.
102
187
  useEffect(() => {
103
188
  if (!enabled || !projectId || !methodId || !runId) {
104
189
  setRaw(null); setEnvelope(null); setLoading(false); setError(null);
105
190
  return;
106
191
  }
192
+ if (selectedCycle == null) {
193
+ setRaw(null); setEnvelope(null); setLoading(false); setError(null);
194
+ return;
195
+ }
107
196
  let cancelled = false;
108
197
  setLoading(true);
109
198
  setError(null);
110
199
  (async () => {
111
200
  try {
112
- const args: Record<string, any> = {
113
- project_id: projectId, method_id: methodId,
114
- run_id: runId, name: blobName,
115
- };
116
- if (selectedCycle != null) args.cycle_index = selectedCycle;
117
201
  const resp: any = await invoke(
118
- 'tis.read_raw' as any, MessageType.Request as any, args as any,
202
+ 'tis.read_raw' as any, MessageType.Request as any,
203
+ {
204
+ project_id: projectId, method_id: methodId,
205
+ run_id: runId, name: blobName,
206
+ cycle_index: selectedCycle,
207
+ } as any,
119
208
  );
120
209
  if (cancelled) return;
121
210
  if (resp?.success) {
@@ -134,7 +223,19 @@ export function useRawCycleData(opts: UseRawCycleDataOptions): UseRawCycleDataRe
134
223
  return () => { cancelled = true; };
135
224
  }, [enabled, projectId, methodId, runId, blobName, selectedCycle, invoke]);
136
225
 
137
- return { cycles, selectedCycle, setSelectedCycle, raw, envelope, loading, error };
226
+ // Public setter wraps setSelectedCycle and flips userPinnedRef so
227
+ // an operator's manual pick freezes the picker for the rest of the
228
+ // run. Re-mounting (new run) clears userPinnedRef in the
229
+ // identifier-change effect above.
230
+ const pickCycle = useCallback((n: number | null) => {
231
+ userPinnedRef.current = n != null;
232
+ setSelectedCycle(n);
233
+ }, []);
234
+
235
+ return {
236
+ cycles, selectedCycle, setSelectedCycle: pickCycle,
237
+ raw, envelope, loading, error,
238
+ };
138
239
  }
139
240
 
140
241
  /**