@adcops/autocore-react 3.3.113 → 3.3.116

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 (69) hide show
  1. package/dist/components/index.d.ts +10 -2
  2. package/dist/components/index.d.ts.map +1 -1
  3. package/dist/components/index.js +1 -1
  4. package/dist/components/tis/CycleConfigDialog.d.ts +39 -0
  5. package/dist/components/tis/CycleConfigDialog.d.ts.map +1 -0
  6. package/dist/components/tis/CycleConfigDialog.js +1 -0
  7. package/dist/components/tis/CycleConfigForm.d.ts +22 -0
  8. package/dist/components/tis/CycleConfigForm.d.ts.map +1 -0
  9. package/dist/components/tis/CycleConfigForm.js +1 -0
  10. package/dist/components/tis/CycleStatusStrip.d.ts +10 -0
  11. package/dist/components/tis/CycleStatusStrip.d.ts.map +1 -0
  12. package/dist/components/tis/CycleStatusStrip.js +1 -0
  13. package/dist/components/tis/ResultHistoryTable.d.ts.map +1 -1
  14. package/dist/components/tis/ResultHistoryTable.js +1 -1
  15. package/dist/components/tis/ScienceTable.d.ts +15 -0
  16. package/dist/components/tis/ScienceTable.d.ts.map +1 -1
  17. package/dist/components/tis/ScienceTable.js +1 -1
  18. package/dist/components/tis/TestDataView.d.ts +14 -4
  19. package/dist/components/tis/TestDataView.d.ts.map +1 -1
  20. package/dist/components/tis/TestDataView.js +1 -1
  21. package/dist/components/tis/TestFieldRow.d.ts +151 -0
  22. package/dist/components/tis/TestFieldRow.d.ts.map +1 -0
  23. package/dist/components/tis/TestFieldRow.js +1 -0
  24. package/dist/components/tis/TestSetupForm.d.ts +15 -51
  25. package/dist/components/tis/TestSetupForm.d.ts.map +1 -1
  26. package/dist/components/tis/TestSetupForm.js +1 -1
  27. package/dist/components/tis/TestSetupView.d.ts.map +1 -1
  28. package/dist/components/tis/TestSetupView.js +1 -1
  29. package/dist/components/tis/TisProvider.d.ts +80 -0
  30. package/dist/components/tis/TisProvider.d.ts.map +1 -1
  31. package/dist/components/tis/TisProvider.js +1 -1
  32. package/dist/components/tis-editor/TisConfigEditor.d.ts.map +1 -1
  33. package/dist/components/tis-editor/TisConfigEditor.js +1 -1
  34. package/dist/components/tis-editor/editor/CycleConfigSection.d.ts +19 -0
  35. package/dist/components/tis-editor/editor/CycleConfigSection.d.ts.map +1 -0
  36. package/dist/components/tis-editor/editor/CycleConfigSection.js +1 -0
  37. package/dist/components/tis-editor/editor/FieldArrayEditor.d.ts.map +1 -1
  38. package/dist/components/tis-editor/editor/FieldArrayEditor.js +1 -1
  39. package/dist/components/tis-editor/editor/MethodFormEditor.d.ts.map +1 -1
  40. package/dist/components/tis-editor/editor/MethodFormEditor.js +1 -1
  41. package/dist/components/tis-editor/editor/TestFieldDialog.d.ts +3 -0
  42. package/dist/components/tis-editor/editor/TestFieldDialog.d.ts.map +1 -1
  43. package/dist/components/tis-editor/editor/TestFieldDialog.js +1 -1
  44. package/dist/components/tis-editor/editor/ViewsEditor.d.ts.map +1 -1
  45. package/dist/components/tis-editor/editor/ViewsEditor.js +1 -1
  46. package/dist/components/tis-editor/types.d.ts +28 -1
  47. package/dist/components/tis-editor/types.d.ts.map +1 -1
  48. package/dist/components/tis-editor/validation.d.ts.map +1 -1
  49. package/dist/components/tis-editor/validation.js +1 -1
  50. package/package.json +110 -110
  51. package/src/components/index.ts +14 -0
  52. package/src/components/tis/CycleConfigDialog.tsx +173 -0
  53. package/src/components/tis/CycleConfigForm.tsx +192 -0
  54. package/src/components/tis/CycleStatusStrip.tsx +84 -0
  55. package/src/components/tis/ResultHistoryTable.tsx +141 -1
  56. package/src/components/tis/ScienceTable.tsx +71 -12
  57. package/src/components/tis/TestDataView.tsx +82 -7
  58. package/src/components/tis/TestFieldRow.tsx +297 -0
  59. package/src/components/tis/TestSetupForm.tsx +52 -212
  60. package/src/components/tis/TestSetupView.tsx +60 -3
  61. package/src/components/tis/TisProvider.tsx +191 -0
  62. package/src/components/tis-editor/TisConfigEditor.tsx +1 -0
  63. package/src/components/tis-editor/editor/CycleConfigSection.tsx +93 -0
  64. package/src/components/tis-editor/editor/FieldArrayEditor.tsx +10 -0
  65. package/src/components/tis-editor/editor/MethodFormEditor.tsx +10 -4
  66. package/src/components/tis-editor/editor/TestFieldDialog.tsx +22 -2
  67. package/src/components/tis-editor/editor/ViewsEditor.tsx +3 -0
  68. package/src/components/tis-editor/types.ts +34 -1
  69. package/src/components/tis-editor/validation.ts +20 -2
@@ -6,12 +6,26 @@ import { Dialog } from 'primereact/dialog';
6
6
  import { EventEmitterContext } from '../../core/EventEmitterContext';
7
7
  import { AutoCoreTagContext } from '../../core/AutoCoreTagContext';
8
8
  import { MessageType } from '../../hub/CommandMessage';
9
- import { ValueInput } from '../ValueInput';
10
9
  import { TextInput } from '../TextInput';
11
10
  import { useTis } from './TisProvider';
12
11
  import { useAmsAssets, useAmsRoles, type AmsAssetEntry } from '../ams/AmsProvider';
13
12
  import { TestMethodDialog } from './TestMethodDialog';
14
13
  import { ConfigurationDialog, configLabelOf } from './ConfigurationDialog';
14
+ import {
15
+ TestFieldRow,
16
+ displayToRaw,
17
+ fieldIsValid,
18
+ fieldIssue,
19
+ labelOf,
20
+ type CycleConfigScope,
21
+ type TestFieldDef,
22
+ } from './TestFieldRow';
23
+
24
+ // The field schema and its display/raw + validation helpers are shared with
25
+ // <CycleConfigForm> — see TestFieldRow.tsx. Re-exported here because this
26
+ // module has been the public home of `TestFieldDef` since before Cycle
27
+ // Configuration existed.
28
+ export type { TestFieldDef } from './TestFieldRow';
15
29
 
16
30
  /**
17
31
  * One asset_ref declared on a test method. We only consume the subset
@@ -27,64 +41,24 @@ interface TisAssetRef {
27
41
  location?: string;
28
42
  }
29
43
 
30
- export interface TestFieldDef {
31
- /** Canonical key — wire format, generated code, on-disk JSON. */
32
- name: string;
33
- type: string;
34
- units?: string;
35
- required?: boolean;
36
- source?: string;
37
- /** Pretty label rendered by the form. Falls back to `name`. Units
38
- * are appended automatically; don't pre-format `[mm]` into label. */
39
- label?: string;
40
- /** Long-form guidance surfaced as a hover tooltip on an info icon. */
41
- description?: string;
42
- /** Seed value applied when the operator selects this test method.
43
- * For source-bound fields the default is written to the GM tag so
44
- * the control program sees it; for non-source fields it's stashed
45
- * straight into stagedConfig. Operator edits override per-stage,
46
- * but the schema default never mutates — re-selecting the method
47
- * re-applies the default. **Authored in display units** when
48
- * `scale` is set — the form divides by `scale` before writing to
49
- * GM / stagedConfig so the underlying storage stays raw. */
50
- default?: any;
51
- /** Optional unit-conversion multiplier. Mirrors AutoCoreTagContext:
52
- * ```
53
- * display = raw * scale
54
- * raw = display / scale
55
- * ```
56
- * Example: backend stores degrees, operator enters revolutions →
57
- * `scale: 0.00277778`. None / 1.0 = no conversion.
58
- *
59
- * Storage stays raw — the form scales only on input and display.
60
- * Cycle and results values are scaled by the corresponding paths
61
- * in TestDataView; the server scales CSV exports too. */
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;
68
- /** Optional fixed set of choices. When present, the field renders
69
- * as a dropdown and the operator must pick one of the declared
70
- * values rather than typing freely. Each entry is either a bare
71
- * scalar (label === value) or an explicit `{ label, value }` pair
72
- * when the displayed text should differ from the stored value.
73
- * Works with any `type`; like `default`, values are authored in
74
- * display units when `scale` is set. */
75
- options?: Array<
76
- | string
77
- | number
78
- | boolean
79
- | { label?: string; value: string | number | boolean }
80
- >;
81
- }
82
-
83
44
  export interface TestMethod {
84
45
  project_fields: TestFieldDef[];
85
46
  config_fields: TestFieldDef[];
86
47
  cycle_fields: TestFieldDef[];
87
48
  results_fields: TestFieldDef[];
49
+ /**
50
+ * Operator-entered per-row annotation, rendered on the Cycle tab by
51
+ * <CycleConfigForm> rather than by this form. Declared here so a
52
+ * generated schema literal typechecks against `TestMethod`.
53
+ */
54
+ cycle_config_fields?: TestFieldDef[];
55
+ /** Which boundary the operator annotates at: `'run'` (default — one
56
+ * latch per Start, stamping every cycle of the run) or `'cycle'`
57
+ * (one latch per cycle, re-gated between cycles). */
58
+ cycle_config_scope?: CycleConfigScope;
59
+ /** Open the Cycle Configuration dialog at the boundary instead of
60
+ * relying on the operator having filled the Cycle tab. */
61
+ prompt_before_cycle?: boolean;
88
62
  /**
89
63
  * AMS asset references resolved at start_test. The form scans these
90
64
  * for `select=by_id_field` entries pointing at a config field
@@ -159,38 +133,6 @@ export interface TestSetupFormProps {
159
133
  // Helpers
160
134
  // -------------------------------------------------------------------------
161
135
 
162
- const labelOf = (f: TestFieldDef): string => {
163
- const base = f.label && f.label.length > 0 ? f.label : f.name;
164
- return f.units ? `${base} [${f.units}]` : base;
165
- };
166
-
167
- /**
168
- * Two helpers for the display ↔ raw boundary on numeric fields.
169
- *
170
- * Convention: `display = raw * scale`, `raw = display / scale`. Matches
171
- * AutoCoreTagContext. Default scale = 1.0 (no conversion). Non-numeric
172
- * input values (string, null, undefined, NaN) pass through unchanged
173
- * — the form has fields of other types that share the change handler.
174
- *
175
- * Storage (config map, GM, on-disk JSON) always holds the raw value.
176
- * Only the inputs the operator looks at and the cells in TestDataView
177
- * use the display value. This way scales can change in project.json
178
- * without invalidating historical records.
179
- */
180
- const rawToDisplay = (raw: any, scale: number | undefined): any => {
181
- if (!scale || scale === 1) return raw;
182
- if (typeof raw !== 'number' || !Number.isFinite(raw)) return raw;
183
- return raw * scale;
184
- };
185
- const displayToRaw = (display: any, scale: number | undefined): any => {
186
- if (!scale || scale === 1) return display;
187
- if (typeof display !== 'number' || !Number.isFinite(display)) return display;
188
- return display / scale;
189
- };
190
-
191
- const hasDescription = (f: TestFieldDef): boolean =>
192
- typeof f.description === 'string' && f.description.length > 0;
193
-
194
136
  /** Matches an FQDN default token like `${gm.safe_speed}` (whole-string). */
195
137
  const DEFAULT_TOKEN_RE = /^\$\{\s*([^}]+?)\s*\}$/;
196
138
 
@@ -230,41 +172,6 @@ const resolveDefaultRaw = (
230
172
  return { raw: displayToRaw(d, field.scale), ok: true };
231
173
  };
232
174
 
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
- // 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}`;
251
- return null;
252
- };
253
-
254
- /**
255
- * Normalise a field's `options` (bare scalars and/or `{label, value}`
256
- * pairs) into the `{ label, value }[]` shape PrimeReact's Dropdown
257
- * wants. Bare scalars use their stringified form as the label.
258
- */
259
- const normalizeOptions = (
260
- opts: TestFieldDef['options'],
261
- ): { label: string; value: string | number | boolean }[] =>
262
- (opts ?? []).map((o) =>
263
- o !== null && typeof o === 'object'
264
- ? { label: String(o.label ?? o.value), value: o.value }
265
- : { label: String(o), value: o },
266
- );
267
-
268
175
  const methodLabelOf = (methodId: string, schema: TestMethod | undefined): string =>
269
176
  (schema?.label && schema.label.length > 0) ? schema.label : methodId;
270
177
 
@@ -550,10 +457,7 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
550
457
 
551
458
  for (const field of schema.config_fields) {
552
459
  if (field.name === 'sample_id') continue;
553
- const v = config[field.name];
554
- const empty = v === undefined || v === '' || v === null;
555
- if (field.required && empty) { valid = false; break; }
556
- if (rangeIssue(field, v)) { valid = false; break; }
460
+ if (fieldIssue(field, config[field.name])) { valid = false; break; }
557
461
  }
558
462
 
559
463
  setIsValid(valid);
@@ -583,23 +487,15 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
583
487
  onValidationChange, invoke,
584
488
  ]);
585
489
 
586
- const isFieldValid = (field: TestFieldDef) => {
587
- const v = config[field.name];
588
- const empty = v === undefined || v === '' || v === null;
589
- if (field.required && empty) return false;
590
- if (rangeIssue(field, v)) return false;
591
- return true;
592
- };
490
+ const isFieldValid = (field: TestFieldDef) => fieldIsValid(field, config[field.name]);
593
491
 
594
492
  const handleSampleIdChange = (value: string) => {
595
493
  setSampleIdLocal(value);
596
494
  };
597
495
 
598
- const handleFieldChange = async (field: TestFieldDef, val: any) => {
599
- // The operator typed a DISPLAY value; convert to RAW before
600
- // storing or writing to GM. Non-numeric fields and identity
601
- // scales pass through unchanged via the helper.
602
- const rawVal = displayToRaw(val, field.scale);
496
+ const handleFieldChange = async (field: TestFieldDef, rawVal: any) => {
497
+ // <TestFieldRow> has already converted the operator's DISPLAY entry
498
+ // to RAW; storage and GM always hold raw.
603
499
  setConfig({ ...config, [field.name]: rawVal });
604
500
  if (field.source) {
605
501
  try { await write(field.source, rawVal); }
@@ -642,81 +538,31 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
642
538
  const renderConfigField = (field: TestFieldDef) => {
643
539
  if (field.name === 'sample_id') return null;
644
540
  const valid = isFieldValid(field);
541
+ // A field an AMS asset_ref claims gets its picker from the registry
542
+ // instead of from the field declaration; everything else is rendered
543
+ // from the schema by <TestFieldRow>, exactly as the Cycle tab does.
645
544
  const assetType = assetTypeForField(field);
646
- // A schema-declared `options` list turns the field into a
647
- // dropdown (skipped when an asset_ref already claims the field
648
- // — that picker is sourced from AMS, not the static list).
649
- const dropdownOptions = !assetType && Array.isArray(field.options) && field.options.length > 0
650
- ? normalizeOptions(field.options)
651
- : null;
652
- const isNum = !assetType && !dropdownOptions
653
- && field.type !== 'string' && field.type !== 'bool';
654
545
  return (
655
- <React.Fragment key={field.name}>
656
- <span className="ac-form-label">{labelOf(field)}</span>
657
- {assetType ? (
546
+ <TestFieldRow
547
+ key={field.name}
548
+ field={field}
549
+ value={config[field.name]}
550
+ valid={valid}
551
+ onChange={(raw) => handleFieldChange(field, raw)}
552
+ onInfo={(f) => setInfoDialog({
553
+ open: true,
554
+ title: labelOf(f),
555
+ body: <p style={{ margin: 0, whiteSpace: 'pre-wrap' }}>{f.description}</p>,
556
+ })}
557
+ control={assetType ? (
658
558
  <AssetIdPicker
659
559
  assetType={assetType}
660
560
  value={config[field.name] != null ? String(config[field.name]) : ''}
661
561
  onChange={(val) => handleFieldChange(field, val)}
662
562
  invalid={!valid}
663
563
  />
664
- ) : dropdownOptions ? (
665
- <Dropdown
666
- value={config[field.name] ?? null}
667
- options={dropdownOptions}
668
- onChange={(e) => handleFieldChange(field, e.value)}
669
- placeholder={`Select ${field.label ?? field.name}…`}
670
- className={`ac-dropdown-clearable${!valid ? ' p-invalid' : ''}`}
671
- showClear={!field.required}
672
- />
673
- ) : isNum ? (
674
- <ValueInput
675
- label={undefined}
676
- // Storage is RAW; render the DISPLAY value so
677
- // operator sees the units they configured. The
678
- // change handler runs the inverse before
679
- // committing back to storage.
680
- value={config[field.name] != null
681
- ? Number(rawToDisplay(Number(config[field.name]), field.scale))
682
- : null}
683
- // min/max are authored in display units, matching the
684
- // value rendered above — ValueInput rejects out-of-range
685
- // entries on accept.
686
- min={field.min}
687
- max={field.max}
688
- onValueChanged={(val) => handleFieldChange(field, val)}
689
- className={!valid ? 'p-invalid' : ''}
690
- />
691
- ) : (
692
- <TextInput
693
- label={undefined}
694
- value={config[field.name] != null ? String(config[field.name]) : ''}
695
- onValueChanged={(val) => handleFieldChange(field, val)}
696
- className={!valid ? 'p-invalid' : ''}
697
- />
698
- )}
699
- {hasDescription(field) ? (
700
- <Button
701
- icon="pi pi-info-circle"
702
- text
703
- rounded
704
- aria-label={`About ${labelOf(field)}`}
705
- onClick={() => setInfoDialog({
706
- open: true,
707
- title: labelOf(field),
708
- body: <p style={{ margin: 0, whiteSpace: 'pre-wrap' }}>{field.description}</p>,
709
- })}
710
- // Replaces a hover Tooltip — touch-friendly. The
711
- // description is shown in the shared info dialog.
712
- />
713
- ) : (
714
- <span aria-hidden="true" />
715
- )}
716
- <span style={{ color: valid ? 'var(--green-500)' : 'var(--red-500)', display: 'flex', alignItems: 'center' }}>
717
- <i className={valid ? 'pi pi-check' : 'pi pi-times'} />
718
- </span>
719
- </React.Fragment>
564
+ ) : undefined}
565
+ />
720
566
  );
721
567
  };
722
568
 
@@ -772,14 +618,8 @@ export const TestSetupForm: React.FC<TestSetupFormProps> = ({
772
618
  if (schema) {
773
619
  for (const field of schema.config_fields) {
774
620
  if (field.name === 'sample_id') continue;
775
- const v = config[field.name];
776
- const empty = v === undefined || v === '' || v === null;
777
- if (field.required && empty) {
778
- issues.push(`Required field "${labelOf(field)}" is empty.`);
779
- continue;
780
- }
781
- const re = rangeIssue(field, v);
782
- if (re) issues.push(`"${labelOf(field)}" ${re}.`);
621
+ const issue = fieldIssue(field, config[field.name]);
622
+ if (issue) issues.push(`"${labelOf(field)}" ${issue}.`);
783
623
  }
784
624
  }
785
625
  return issues;
@@ -6,7 +6,7 @@
6
6
  * the same tabbed layout and scroll behaviour without copy-pasting a
7
7
  * per-project `TestInformationView`.
8
8
  *
9
- * Two tabs, both self-driving from <TisProvider> (no prop threading):
9
+ * Up to three tabs, all self-driving from <TisProvider> (no prop threading):
10
10
  *
11
11
  * Project tab — <ProjectSelector> stays pinned at the top; the
12
12
  * <ResultHistoryTable> (Test History) scrolls beneath
@@ -14,6 +14,16 @@
14
14
  * Test tab — <TestSetupForm>. Its "Test Setup" header (Sample ID,
15
15
  * Test Method, Configuration) stays pinned; the "Test
16
16
  * Configuration" field list scrolls when it overflows.
17
+ * Cycle tab — <CycleConfigForm>, the per-row annotation recorded onto
18
+ * each cycle. Present only when the selected method
19
+ * declares `cycle_config_fields`, and badged when a
20
+ * required entry is missing. Auto-selected when a run
21
+ * goes active: Test setup is a pre-run concern, the
22
+ * annotation is a during-run one.
23
+ *
24
+ * The Cycle tab is deliberately not the only place this state is visible —
25
+ * an operator about to press Start is looking at the footer, not at a tab.
26
+ * Put <CycleStatusStrip> beside the Start button for that.
17
27
  *
18
28
  * The whole view fills its parent's height and confines overflow to the
19
29
  * inner scroll regions, so the tab strip and the section headers stay
@@ -22,11 +32,13 @@
22
32
  * `min-height: 0`, or a fixed-height panel) to get that behaviour.
23
33
  */
24
34
 
25
- import React from 'react';
35
+ import React, { useEffect, useRef, useState } from 'react';
26
36
  import { TabView, TabPanel } from 'primereact/tabview';
27
37
  import { ProjectSelector } from './ProjectSelector';
28
38
  import { ResultHistoryTable } from './ResultHistoryTable';
29
39
  import { TestSetupForm } from './TestSetupForm';
40
+ import { CycleConfigForm } from './CycleConfigForm';
41
+ import { useTis } from './TisProvider';
30
42
 
31
43
  export interface TestSetupViewProps {
32
44
  /** Extra class names applied to the root element. */
@@ -40,9 +52,25 @@ export const TestSetupView: React.FC<TestSetupViewProps> = ({ className, style }
40
52
  ? `ac-test-setup-view ${className}`
41
53
  : 'ac-test-setup-view';
42
54
 
55
+ const tis = useTis();
56
+ const hasCycleConfig = tis.cycleConfigFields.length > 0;
57
+ const cycleIncomplete = hasCycleConfig && !tis.cycleConfig.ready;
58
+
59
+ // Which tab is showing. Controlled so the view can move the operator to
60
+ // the Cycle tab when a run goes active — the annotation is what they
61
+ // need next, and Test setup is already done by then.
62
+ const [activeIndex, setActiveIndex] = useState(0);
63
+ const prevActive = useRef(false);
64
+ useEffect(() => {
65
+ if (tis.state.active && !prevActive.current && hasCycleConfig) {
66
+ setActiveIndex(2);
67
+ }
68
+ prevActive.current = tis.state.active;
69
+ }, [tis.state.active, hasCycleConfig]);
70
+
43
71
  return (
44
72
  <div className={rootClass} style={style}>
45
- <TabView>
73
+ <TabView activeIndex={activeIndex} onTabChange={(e) => setActiveIndex(e.index)}>
46
74
  {/* Project tab: pick or create a project, then browse its
47
75
  history. ResultHistoryTable is project-scoped (across
48
76
  methods) by default, so this is the natural place to
@@ -66,6 +94,35 @@ export const TestSetupView: React.FC<TestSetupViewProps> = ({ className, style }
66
94
  <TabPanel header="Test">
67
95
  <TestSetupForm />
68
96
  </TabPanel>
97
+
98
+ {/* Cycle tab: per-row operator annotation. Rendered only
99
+ when the method declares any — a method that records
100
+ nothing by hand shouldn't grow an empty tab. The header
101
+ carries a warning marker when a required entry is
102
+ missing, so the state is legible from the tab strip
103
+ without opening it. */}
104
+ {hasCycleConfig && (
105
+ <TabPanel
106
+ header={
107
+ <span style={{ display: 'inline-flex', alignItems: 'center', gap: '0.4rem' }}>
108
+ Cycle
109
+ {cycleIncomplete && (
110
+ <i
111
+ className="pi pi-exclamation-circle"
112
+ style={{ color: 'var(--red-500)' }}
113
+ aria-label="Cycle Configuration incomplete"
114
+ />
115
+ )}
116
+ </span>
117
+ }
118
+ >
119
+ <div className="ac-test-setup-view__tab">
120
+ <div className="ac-test-setup-view__scroll">
121
+ <CycleConfigForm />
122
+ </div>
123
+ </div>
124
+ </TabPanel>
125
+ )}
69
126
  </TabView>
70
127
  </div>
71
128
  );