@finos/legend-application-studio 28.21.3 → 28.21.4

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 (42) hide show
  1. package/lib/components/editor/editor-group/data-editor/RelationElementsDataEditor.d.ts.map +1 -1
  2. package/lib/components/editor/editor-group/data-editor/RelationElementsDataEditor.js +63 -20
  3. package/lib/components/editor/editor-group/data-editor/RelationElementsDataEditor.js.map +1 -1
  4. package/lib/components/editor/editor-group/dataProduct/DataProductEditor.d.ts.map +1 -1
  5. package/lib/components/editor/editor-group/dataProduct/DataProductEditor.js +9 -1
  6. package/lib/components/editor/editor-group/dataProduct/DataProductEditor.js.map +1 -1
  7. package/lib/components/editor/editor-group/dataProduct/testable/DataProductTestableEditor.d.ts +23 -0
  8. package/lib/components/editor/editor-group/dataProduct/testable/DataProductTestableEditor.d.ts.map +1 -0
  9. package/lib/components/editor/editor-group/dataProduct/testable/DataProductTestableEditor.js +230 -0
  10. package/lib/components/editor/editor-group/dataProduct/testable/DataProductTestableEditor.js.map +1 -0
  11. package/lib/components/editor/editor-group/testable/TestableSharedComponents.d.ts.map +1 -1
  12. package/lib/components/editor/editor-group/testable/TestableSharedComponents.js +39 -5
  13. package/lib/components/editor/editor-group/testable/TestableSharedComponents.js.map +1 -1
  14. package/lib/index.css +2 -2
  15. package/lib/index.css.map +1 -1
  16. package/lib/package.json +1 -1
  17. package/lib/stores/editor/editor-state/element-editor-state/data/EmbeddedDataState.d.ts +16 -1
  18. package/lib/stores/editor/editor-state/element-editor-state/data/EmbeddedDataState.d.ts.map +1 -1
  19. package/lib/stores/editor/editor-state/element-editor-state/data/EmbeddedDataState.js +36 -1
  20. package/lib/stores/editor/editor-state/element-editor-state/data/EmbeddedDataState.js.map +1 -1
  21. package/lib/stores/editor/editor-state/element-editor-state/dataProduct/DataProductEditorState.d.ts +4 -1
  22. package/lib/stores/editor/editor-state/element-editor-state/dataProduct/DataProductEditorState.d.ts.map +1 -1
  23. package/lib/stores/editor/editor-state/element-editor-state/dataProduct/DataProductEditorState.js +4 -0
  24. package/lib/stores/editor/editor-state/element-editor-state/dataProduct/DataProductEditorState.js.map +1 -1
  25. package/lib/stores/editor/editor-state/element-editor-state/dataProduct/testable/DataProductTestableState.d.ts +118 -0
  26. package/lib/stores/editor/editor-state/element-editor-state/dataProduct/testable/DataProductTestableState.d.ts.map +1 -0
  27. package/lib/stores/editor/editor-state/element-editor-state/dataProduct/testable/DataProductTestableState.js +600 -0
  28. package/lib/stores/editor/editor-state/element-editor-state/dataProduct/testable/DataProductTestableState.js.map +1 -0
  29. package/lib/stores/editor/editor-state/element-editor-state/testable/TestAssertionState.d.ts +17 -1
  30. package/lib/stores/editor/editor-state/element-editor-state/testable/TestAssertionState.d.ts.map +1 -1
  31. package/lib/stores/editor/editor-state/element-editor-state/testable/TestAssertionState.js +46 -1
  32. package/lib/stores/editor/editor-state/element-editor-state/testable/TestAssertionState.js.map +1 -1
  33. package/package.json +9 -9
  34. package/src/components/editor/editor-group/data-editor/RelationElementsDataEditor.tsx +135 -49
  35. package/src/components/editor/editor-group/dataProduct/DataProductEditor.tsx +14 -0
  36. package/src/components/editor/editor-group/dataProduct/testable/DataProductTestableEditor.tsx +872 -0
  37. package/src/components/editor/editor-group/testable/TestableSharedComponents.tsx +169 -15
  38. package/src/stores/editor/editor-state/element-editor-state/data/EmbeddedDataState.ts +54 -1
  39. package/src/stores/editor/editor-state/element-editor-state/dataProduct/DataProductEditorState.ts +4 -0
  40. package/src/stores/editor/editor-state/element-editor-state/dataProduct/testable/DataProductTestableState.ts +863 -0
  41. package/src/stores/editor/editor-state/element-editor-state/testable/TestAssertionState.ts +66 -0
  42. package/tsconfig.json +2 -0
@@ -0,0 +1,863 @@
1
+ /**
2
+ * Copyright (c) 2020-present, Goldman Sachs
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ import {
18
+ type Accessor,
19
+ type AccessPoint,
20
+ type PackageableElement,
21
+ type TestSuite,
22
+ type RawLambda,
23
+ type AccessorOwner,
24
+ DataProduct,
25
+ FunctionAccessPoint,
26
+ LakehouseAccessPoint,
27
+ DataProductTestSuite,
28
+ BaseDataResolver,
29
+ DataProductAccessPointTest,
30
+ RelationElementsData,
31
+ RelationElement,
32
+ PackageableElementExplicitReference,
33
+ TestExecuted,
34
+ TestError,
35
+ TestExecutionStatus,
36
+ EqualToRelation,
37
+ observe_RelationElement,
38
+ observe_RelationElementsData,
39
+ observe_DataProductTestSuite,
40
+ IngestDefinition,
41
+ getAccessorItemLabelForElement,
42
+ } from '@finos/legend-graph';
43
+ import {
44
+ type GeneratorFn,
45
+ ActionState,
46
+ assertErrorThrown,
47
+ deleteEntry,
48
+ addUniqueEntry,
49
+ uuid,
50
+ noop,
51
+ } from '@finos/legend-shared';
52
+ import {
53
+ action,
54
+ flow,
55
+ flowResult,
56
+ makeObservable,
57
+ observable,
58
+ runInAction,
59
+ } from 'mobx';
60
+ import type { EditorStore } from '../../../../EditorStore.js';
61
+ import type { DataProductEditorState } from '../DataProductEditorState.js';
62
+ import { RelationElementState } from '../../data/EmbeddedDataState.js';
63
+ import { TESTABLE_RESULT } from '../../../../sidebar-state/testable/GlobalTestRunnerState.js';
64
+ import { testSuite_addTest } from '../../../../../graph-modifier/Testable_GraphModifierHelper.js';
65
+ import {
66
+ TestableTestEditorState,
67
+ TestableTestSuiteEditorState,
68
+ } from '../../testable/TestableEditorState.js';
69
+
70
+ const createEmptyRelationElement = (
71
+ itemId: string,
72
+ columns: string[] = [],
73
+ ): RelationElement => {
74
+ const relationElement = new RelationElement();
75
+ relationElement.paths = [itemId];
76
+ relationElement.columns = columns;
77
+ relationElement.rows = [];
78
+ return observe_RelationElement(relationElement);
79
+ };
80
+
81
+ /**
82
+ * Returns the lambda for an access point (handles both LakehouseAccessPoint
83
+ * and FunctionAccessPoint).
84
+ */
85
+ const getAccessPointLambda = (
86
+ accessPoint: AccessPoint,
87
+ ): RawLambda | undefined =>
88
+ accessPoint instanceof LakehouseAccessPoint
89
+ ? accessPoint.func
90
+ : accessPoint instanceof FunctionAccessPoint
91
+ ? accessPoint.query
92
+ : undefined;
93
+
94
+ /**
95
+ * Builds a RelationElementsData from resolved accessors using the accessor
96
+ * relation type as the source of truth for column definitions.
97
+ */
98
+ const buildRelationElementsDataWithColumns = (
99
+ accs: Accessor[],
100
+ ): RelationElementsData => {
101
+ const relData = new RelationElementsData();
102
+ relData.relationElements = accs.map((acc) => {
103
+ const itemId = acc.accessor || 'UNKNOWN';
104
+ const columns = acc.relationType.columns.map((column) => column.name);
105
+ return createEmptyRelationElement(itemId, columns);
106
+ });
107
+ return relData;
108
+ };
109
+
110
+ const isIngestOrDataProductAccessor = (
111
+ accessor: Accessor,
112
+ ): accessor is Accessor =>
113
+ accessor.parentElement instanceof DataProduct ||
114
+ accessor.parentElement instanceof IngestDefinition;
115
+
116
+ const getAccessPointDisplayLabel = (accessPoint: AccessPoint): string =>
117
+ accessPoint.id;
118
+
119
+ interface ElementDataItem {
120
+ id: string;
121
+ label: string;
122
+ }
123
+
124
+ const getElementDataItems = (
125
+ element: PackageableElement,
126
+ ): ElementDataItem[] => {
127
+ if (element instanceof DataProduct) {
128
+ return element.accessPointGroups
129
+ .flatMap((g) => g.accessPoints)
130
+ .map((ap) => ({
131
+ id: ap.id,
132
+ label: getAccessPointDisplayLabel(ap),
133
+ }));
134
+ }
135
+ if (element instanceof IngestDefinition) {
136
+ return (element.TEMPORARY_MATVIEW_FUNCTION_DATA_SETS ?? []).map((ds) => ({
137
+ id: ds.name,
138
+ label: ds.name,
139
+ }));
140
+ }
141
+ return [];
142
+ };
143
+
144
+ const inferDataProductItemColumns = async (
145
+ editorStore: EditorStore,
146
+ dataProduct: DataProduct,
147
+ itemId: string,
148
+ ): Promise<string[] | undefined> => {
149
+ const accessPoint = dataProduct.accessPointGroups
150
+ .flatMap((group) => group.accessPoints)
151
+ .find((ap) => ap.id === itemId);
152
+ if (!accessPoint) {
153
+ return undefined;
154
+ }
155
+ const lambda = getAccessPointLambda(accessPoint);
156
+ if (!lambda) {
157
+ return undefined;
158
+ }
159
+ const relationMetadata =
160
+ await editorStore.graphManagerState.graphManager.getLambdaRelationType(
161
+ lambda,
162
+ editorStore.graphManagerState.graph,
163
+ );
164
+ return relationMetadata.columns.map((column) => column.name);
165
+ };
166
+
167
+ // ─── Per-test state ──────────────────────────────────────────────────────────
168
+
169
+ export class DataProductTestState extends TestableTestEditorState {
170
+ readonly suiteState: DataProductTestSuiteState;
171
+ override test: DataProductAccessPointTest;
172
+ readonly uuid = uuid();
173
+
174
+ /** Wraps assertion.expected — drives both column definitions and test data rows. */
175
+ testDataRelationState: RelationElementState | undefined;
176
+
177
+ constructor(
178
+ suiteState: DataProductTestSuiteState,
179
+ test: DataProductAccessPointTest,
180
+ ) {
181
+ super(
182
+ suiteState.testableState.dataProduct,
183
+ test,
184
+ suiteState.testableState.dataProductEditorState.isReadOnly,
185
+ suiteState.editorStore,
186
+ );
187
+ makeObservable(this, {
188
+ // observable fields from base class
189
+ selectedAsertionState: observable,
190
+ selectedTab: observable,
191
+ assertionToRename: observable,
192
+ assertionEditorStates: observable,
193
+ testResultState: observable,
194
+ runningTestAction: observable,
195
+ // own observable
196
+ testDataRelationState: observable,
197
+ // actions from base class
198
+ setSelectedTab: action,
199
+ setAssertionToRename: action,
200
+ addAssertion: action,
201
+ deleteAssertion: action,
202
+ openAssertion: action,
203
+ resetResult: action,
204
+ handleTestResult: action,
205
+ // flow from base class
206
+ runTest: flow,
207
+ });
208
+ this.suiteState = suiteState;
209
+ this.test = test;
210
+ this.buildTestDataRelationState().catch(noop());
211
+ }
212
+
213
+ private async buildTestDataRelationState(): Promise<void> {
214
+ const assertion = this.test.assertions.find(
215
+ (a): a is EqualToRelation => a instanceof EqualToRelation,
216
+ );
217
+ if (!assertion) {
218
+ return;
219
+ }
220
+ // Populate columns from the engine if not yet set
221
+ if (assertion.expected.columns.length === 0) {
222
+ try {
223
+ const engineColumns = await inferDataProductItemColumns(
224
+ this.editorStore,
225
+ this.suiteState.testableState.dataProduct,
226
+ this.test.accessPointId,
227
+ );
228
+ if (engineColumns && engineColumns.length > 0) {
229
+ runInAction(() => {
230
+ assertion.expected.columns = engineColumns;
231
+ });
232
+ }
233
+ } catch {
234
+ // best-effort; continue with empty columns
235
+ }
236
+ }
237
+ runInAction(() => {
238
+ this.testDataRelationState = new RelationElementState(assertion.expected);
239
+ });
240
+ }
241
+
242
+ get accessPointLabel(): string {
243
+ return this.suiteState.testableState.getOwnAccessPointLabel(
244
+ this.test.accessPointId,
245
+ );
246
+ }
247
+ }
248
+
249
+ // ─── Per-element test data state ─────────────────────────────────────────────
250
+
251
+ export class DataProductElementTestDataState {
252
+ readonly testDataState: DataProductTestDataState;
253
+ readonly testData: BaseDataResolver;
254
+ readonly editorStore: EditorStore;
255
+
256
+ /** Currently selected dataset/item within this element's RelationElementsData */
257
+ selectedItemId: string | undefined;
258
+ /** RelationElementState for the currently selected item */
259
+ relationElementState: RelationElementState | undefined;
260
+
261
+ constructor(
262
+ testDataState: DataProductTestDataState,
263
+ testData: BaseDataResolver,
264
+ ) {
265
+ makeObservable(this, {
266
+ selectedItemId: observable,
267
+ relationElementState: observable,
268
+ setSelectedItem: action,
269
+ });
270
+ this.testDataState = testDataState;
271
+ this.testData = testData;
272
+ this.editorStore = testDataState.editorStore;
273
+ // Select the first item that has data
274
+ if (testData.data instanceof RelationElementsData) {
275
+ const firstRel = testData.data.relationElements[0];
276
+ if (firstRel) {
277
+ this.selectedItemId = firstRel.paths[0];
278
+ this.relationElementState = new RelationElementState(firstRel);
279
+ }
280
+ }
281
+ }
282
+
283
+ get element(): PackageableElement {
284
+ return this.testData.element.value;
285
+ }
286
+
287
+ get elementName(): string {
288
+ return this.testData.element.value.name;
289
+ }
290
+
291
+ get itemLabel(): string {
292
+ return getAccessorItemLabelForElement(this.element as AccessorOwner);
293
+ }
294
+
295
+ get configuredItemIds(): string[] {
296
+ if (this.testData.data instanceof RelationElementsData) {
297
+ return this.testData.data.relationElements.map((re) => re.paths[0] ?? '');
298
+ }
299
+ return [];
300
+ }
301
+
302
+ get configuredItems(): ElementDataItem[] {
303
+ const availableItemsById = new Map(
304
+ getElementDataItems(this.element).map((item) => [item.id, item.label]),
305
+ );
306
+ return this.configuredItemIds.map((id) => ({
307
+ id,
308
+ label: availableItemsById.get(id) ?? id,
309
+ }));
310
+ }
311
+
312
+ setSelectedItem(itemId: string | undefined): void {
313
+ this.selectedItemId = itemId;
314
+ if (itemId && this.testData.data instanceof RelationElementsData) {
315
+ const relEl = this.testData.data.relationElements.find(
316
+ (re) => re.paths[0] === itemId,
317
+ );
318
+ this.relationElementState = relEl
319
+ ? new RelationElementState(relEl)
320
+ : undefined;
321
+ } else {
322
+ this.relationElementState = undefined;
323
+ }
324
+ }
325
+ }
326
+
327
+ // ─── Test data state for a suite ─────────────────────────────────────────────
328
+
329
+ export class DataProductTestDataState {
330
+ readonly editorStore: EditorStore;
331
+ readonly suiteState: DataProductTestSuiteState;
332
+
333
+ elementTestDataStates: DataProductElementTestDataState[] = [];
334
+ selectedElementTestDataState: DataProductElementTestDataState | undefined;
335
+
336
+ constructor(
337
+ suiteState: DataProductTestSuiteState,
338
+ options?: {
339
+ selectedElementPath?: string | undefined;
340
+ selectedItemId?: string | undefined;
341
+ },
342
+ ) {
343
+ makeObservable(this, {
344
+ elementTestDataStates: observable,
345
+ selectedElementTestDataState: observable,
346
+ setSelectedElementTestDataState: action,
347
+ refreshElementTestDataStates: action,
348
+ });
349
+ this.editorStore = suiteState.editorStore;
350
+ this.suiteState = suiteState;
351
+ this.refreshElementTestDataStates(options);
352
+ }
353
+
354
+ refreshElementTestDataStates(options?: {
355
+ selectedElementPath?: string | undefined;
356
+ selectedItemId?: string | undefined;
357
+ }): void {
358
+ const previouslySelectedElementPath =
359
+ options?.selectedElementPath ??
360
+ this.selectedElementTestDataState?.element.path;
361
+ const previouslySelectedItemId =
362
+ options?.selectedItemId ??
363
+ this.selectedElementTestDataState?.selectedItemId;
364
+ const suite = this.suiteState.suite;
365
+ this.elementTestDataStates = (suite.testData ?? [])
366
+ .filter((td): td is BaseDataResolver => td instanceof BaseDataResolver)
367
+ .map((td) => new DataProductElementTestDataState(this, td));
368
+
369
+ this.selectedElementTestDataState =
370
+ this.elementTestDataStates.find(
371
+ (state) => state.element.path === previouslySelectedElementPath,
372
+ ) ?? this.elementTestDataStates[0];
373
+
374
+ if (this.selectedElementTestDataState && previouslySelectedItemId) {
375
+ const nextSelectedItemId =
376
+ this.selectedElementTestDataState.configuredItemIds.find(
377
+ (itemId) => itemId === previouslySelectedItemId,
378
+ ) ?? this.selectedElementTestDataState.configuredItemIds[0];
379
+ this.selectedElementTestDataState.setSelectedItem(nextSelectedItemId);
380
+ }
381
+ }
382
+
383
+ setSelectedElementTestDataState(
384
+ val: DataProductElementTestDataState | undefined,
385
+ ): void {
386
+ this.selectedElementTestDataState = val;
387
+ }
388
+ }
389
+
390
+ // ─── Per-suite state ─────────────────────────────────────────────────────────
391
+
392
+ export class DataProductTestSuiteState extends TestableTestSuiteEditorState {
393
+ readonly testableState: DataProductTestableState;
394
+ override suite: DataProductTestSuite;
395
+ override testStates: DataProductTestState[] = [];
396
+ declare selectTestState: DataProductTestState | undefined;
397
+ testDataState: DataProductTestDataState;
398
+
399
+ constructor(
400
+ editorStore: EditorStore,
401
+ testableState: DataProductTestableState,
402
+ suite: DataProductTestSuite,
403
+ ) {
404
+ super(
405
+ testableState.dataProduct,
406
+ suite,
407
+ testableState.dataProductEditorState.isReadOnly,
408
+ editorStore,
409
+ );
410
+ makeObservable(this, {
411
+ testStates: observable,
412
+ selectTestState: observable,
413
+ testDataState: observable,
414
+ addNewTest: flow,
415
+ deleteTest: action,
416
+ runSuite: flow,
417
+ runFailingTests: flow,
418
+ buildTestStates: action,
419
+ });
420
+ this.testableState = testableState;
421
+ this.suite = suite;
422
+ this.testDataState = new DataProductTestDataState(this);
423
+ this.buildTestStates();
424
+ }
425
+
426
+ refreshTestDataState(): void {
427
+ this.testDataState = new DataProductTestDataState(this, {
428
+ selectedElementPath:
429
+ this.testDataState.selectedElementTestDataState?.element.path,
430
+ selectedItemId:
431
+ this.testDataState.selectedElementTestDataState?.selectedItemId,
432
+ });
433
+ }
434
+
435
+ buildTestStates(): void {
436
+ this.testStates = this.suite.tests.map(
437
+ (t) => new DataProductTestState(this, t as DataProductAccessPointTest),
438
+ );
439
+ this.selectTestState = this.testStates[0];
440
+ }
441
+
442
+ override deleteTest(test: DataProductAccessPointTest): void {
443
+ super.deleteTest(test);
444
+ }
445
+
446
+ *addNewTest(
447
+ testName: string,
448
+ accessPointId: string,
449
+ ): GeneratorFn<string | undefined> {
450
+ const observerContext =
451
+ this.editorStore.changeDetectionState.observerContext;
452
+
453
+ let inferredColumns: string[] = [];
454
+ try {
455
+ const cols = (yield inferDataProductItemColumns(
456
+ this.editorStore,
457
+ this.testableState.dataProduct,
458
+ accessPointId,
459
+ )) as string[] | undefined;
460
+ if (cols) {
461
+ inferredColumns = cols;
462
+ }
463
+ } catch {
464
+ // best-effort
465
+ }
466
+
467
+ const test = new DataProductAccessPointTest();
468
+ test.id = testName;
469
+ test.__parent = this.suite;
470
+ test.accessPointId = accessPointId;
471
+
472
+ // Resolve sources through the graph (follows function calls, no self-refs)
473
+ const accessPointForTest = this.testableState.dataProduct.accessPointGroups
474
+ .flatMap((g) => g.accessPoints)
475
+ .find((ap) => ap.id === accessPointId);
476
+ const rawLambdaForTest = accessPointForTest
477
+ ? getAccessPointLambda(accessPointForTest)
478
+ : undefined;
479
+
480
+ let resolvedAccessors: Accessor[] = [];
481
+ if (rawLambdaForTest) {
482
+ const all =
483
+ (yield this.editorStore.graphManagerState.graphManager.collectAccessorsInRawLambda(
484
+ rawLambdaForTest,
485
+ this.editorStore.graphManagerState.graph,
486
+ )) as Accessor[];
487
+ resolvedAccessors = all.filter(
488
+ (accessor) =>
489
+ isIngestOrDataProductAccessor(accessor) &&
490
+ accessor.parentElement.path !== this.testableState.dataProduct.path,
491
+ );
492
+ }
493
+
494
+ if (resolvedAccessors.length > 0) {
495
+ // Group by element, merge into existing resolvers or create new ones
496
+ const byElement = new Map<string, Accessor[]>();
497
+ for (const acc of resolvedAccessors) {
498
+ const grp = byElement.get(acc.parentElement.path) ?? [];
499
+ grp.push(acc);
500
+ byElement.set(acc.parentElement.path, grp);
501
+ }
502
+ for (const [elementPath, accs] of byElement) {
503
+ const element =
504
+ this.editorStore.graphManagerState.graph.getNullableElement(
505
+ elementPath,
506
+ );
507
+ if (!element) {
508
+ continue;
509
+ }
510
+ const existingResolver = this.suite.testData?.find(
511
+ (td): td is BaseDataResolver =>
512
+ td instanceof BaseDataResolver && td.element.value === element,
513
+ );
514
+ const relData =
515
+ existingResolver?.data instanceof RelationElementsData
516
+ ? existingResolver.data
517
+ : undefined;
518
+ if (!existingResolver || !relData) {
519
+ const resolver = new BaseDataResolver();
520
+ resolver.element =
521
+ PackageableElementExplicitReference.create(element);
522
+ const newRelData = buildRelationElementsDataWithColumns(accs);
523
+ observe_RelationElementsData(newRelData);
524
+ resolver.data = newRelData;
525
+ this.suite.testData = [...(this.suite.testData ?? []), resolver];
526
+ } else {
527
+ for (const acc of accs) {
528
+ const itemId = acc.accessor;
529
+ if (
530
+ !relData.relationElements.find((re) => re.paths[0] === itemId)
531
+ ) {
532
+ const columns = acc.relationType.columns.map(
533
+ (column) => column.name,
534
+ );
535
+ relData.relationElements.push(
536
+ createEmptyRelationElement(itemId, columns),
537
+ );
538
+ }
539
+ }
540
+ }
541
+ }
542
+ } else {
543
+ // Fallback: single resolver on current DP
544
+ let relationData = this.suite.testData?.find(
545
+ (td): td is BaseDataResolver =>
546
+ td instanceof BaseDataResolver &&
547
+ td.element.value === this.testableState.dataProduct &&
548
+ td.data instanceof RelationElementsData,
549
+ )?.data as RelationElementsData | undefined;
550
+
551
+ if (!relationData) {
552
+ const testData = new BaseDataResolver();
553
+ testData.element = PackageableElementExplicitReference.create(
554
+ this.testableState.dataProduct,
555
+ );
556
+ relationData = new RelationElementsData();
557
+ relationData.relationElements = [];
558
+ observe_RelationElementsData(relationData);
559
+ testData.data = relationData;
560
+ this.suite.testData = [...(this.suite.testData ?? []), testData];
561
+ }
562
+ if (
563
+ !relationData.relationElements.find(
564
+ (re) => re.paths[0] === accessPointId,
565
+ )
566
+ ) {
567
+ relationData.relationElements.push(
568
+ createEmptyRelationElement(accessPointId, inferredColumns),
569
+ );
570
+ }
571
+ }
572
+
573
+ const assertion = new EqualToRelation();
574
+ assertion.id = 'assert_1';
575
+ const expectedRelElement = new RelationElement();
576
+ expectedRelElement.paths = [accessPointId];
577
+ expectedRelElement.columns = inferredColumns;
578
+ expectedRelElement.rows = [];
579
+ observe_RelationElement(expectedRelElement);
580
+ assertion.expected = expectedRelElement;
581
+ test.assertions = [assertion];
582
+
583
+ testSuite_addTest(this.suite, test, observerContext);
584
+ this.refreshTestDataState();
585
+
586
+ const testState = new DataProductTestState(this, test);
587
+ this.testStates.push(testState);
588
+ this.selectTestState = testState;
589
+ return undefined;
590
+ }
591
+
592
+ get result(): TESTABLE_RESULT {
593
+ if (this.runningSuiteState.isInProgress) {
594
+ return TESTABLE_RESULT.IN_PROGRESS;
595
+ }
596
+ if (this.testStates.length === 0) {
597
+ return TESTABLE_RESULT.DID_NOT_RUN;
598
+ }
599
+ if (
600
+ this.testStates.every((ts) => ts.testResultState.result === undefined)
601
+ ) {
602
+ return TESTABLE_RESULT.DID_NOT_RUN;
603
+ }
604
+ let hasFailure = false;
605
+ let hasError = false;
606
+ for (const testState of this.testStates) {
607
+ const result = testState.testResultState.result;
608
+ if (result instanceof TestError) {
609
+ hasError = true;
610
+ } else if (
611
+ result instanceof TestExecuted &&
612
+ result.testExecutionStatus === TestExecutionStatus.FAIL
613
+ ) {
614
+ hasFailure = true;
615
+ }
616
+ }
617
+ if (hasError) {
618
+ return TESTABLE_RESULT.ERROR;
619
+ }
620
+ if (hasFailure) {
621
+ return TESTABLE_RESULT.FAILED;
622
+ }
623
+ return TESTABLE_RESULT.PASSED;
624
+ }
625
+ }
626
+
627
+ // ─── Top-level testable state ────────────────────────────────────────────────
628
+
629
+ export class DataProductTestableState {
630
+ readonly editorStore: EditorStore;
631
+ readonly dataProductEditorState: DataProductEditorState;
632
+
633
+ suiteStates: DataProductTestSuiteState[] = [];
634
+ selectedSuiteState: DataProductTestSuiteState | undefined;
635
+ runningAllTestsState = ActionState.create();
636
+ showCreateSuiteModal = false;
637
+ showCreateTestModal = false;
638
+ suiteToRename: TestSuite | undefined;
639
+
640
+ constructor(dataProductEditorState: DataProductEditorState) {
641
+ makeObservable(this, {
642
+ suiteStates: observable,
643
+ selectedSuiteState: observable,
644
+ showCreateSuiteModal: observable,
645
+ showCreateTestModal: observable,
646
+ suiteToRename: observable,
647
+ setSelectedSuiteState: action,
648
+ setShowCreateSuiteModal: action,
649
+ setShowCreateTestModal: action,
650
+ setSuiteToRename: action,
651
+ changeSuite: action,
652
+ createSuite: flow,
653
+ deleteSuite: action,
654
+ init: action,
655
+ runAllTests: flow,
656
+ });
657
+ this.editorStore = dataProductEditorState.editorStore;
658
+ this.dataProductEditorState = dataProductEditorState;
659
+ }
660
+
661
+ get dataProduct(): DataProduct {
662
+ return this.dataProductEditorState.product;
663
+ }
664
+
665
+ setSelectedSuiteState(val: DataProductTestSuiteState | undefined): void {
666
+ this.selectedSuiteState = val;
667
+ }
668
+
669
+ setShowCreateSuiteModal(val: boolean): void {
670
+ this.showCreateSuiteModal = val;
671
+ }
672
+
673
+ setShowCreateTestModal(val: boolean): void {
674
+ this.showCreateTestModal = val;
675
+ }
676
+
677
+ setSuiteToRename(val: TestSuite | undefined): void {
678
+ this.suiteToRename = val;
679
+ }
680
+
681
+ changeSuite(suite: TestSuite): void {
682
+ const suiteState = this.suiteStates.find((s) => s.suite === suite);
683
+ if (suiteState) {
684
+ this.selectedSuiteState = suiteState;
685
+ }
686
+ }
687
+
688
+ /**
689
+ * Build suite states from the DataProduct.tests array.
690
+ * Call this on init and after grammar→form roundtrip.
691
+ */
692
+ init(): void {
693
+ const dp = this.dataProduct;
694
+ this.suiteStates = dp.tests.map(
695
+ (s) => new DataProductTestSuiteState(this.editorStore, this, s),
696
+ );
697
+ this.selectedSuiteState = this.suiteStates[0];
698
+ }
699
+
700
+ /** Returns all graph ingest elements available in the model. */
701
+ get availableIngestSources(): IngestDefinition[] {
702
+ const graph = this.editorStore.graphManagerState.graph;
703
+ return graph.ingests;
704
+ }
705
+
706
+ /** Access points on the DataProduct being edited (used for test's accessPointId). */
707
+ get ownAccessPoints(): AccessPoint[] {
708
+ return this.dataProduct.accessPointGroups.flatMap((g) => g.accessPoints);
709
+ }
710
+
711
+ getOwnAccessPointLabel(accessPointId: string): string {
712
+ const accessPoint = this.ownAccessPoints.find(
713
+ (candidate) => candidate.id === accessPointId,
714
+ );
715
+ return accessPoint
716
+ ? getAccessPointDisplayLabel(accessPoint)
717
+ : accessPointId;
718
+ }
719
+
720
+ /**
721
+ * Create a new test suite with one initial test on the DataProduct.
722
+ * Test data is auto-seeded for the selected access point on the current
723
+ * DataProduct (no element picker required).
724
+ * Columns are inferred via the engine when possible.
725
+ */
726
+ *createSuite(
727
+ suiteName: string,
728
+ testName: string,
729
+ accessPointId: string,
730
+ ): GeneratorFn<string | undefined> {
731
+ const dp = this.dataProduct;
732
+ const observerContext =
733
+ this.editorStore.changeDetectionState.observerContext;
734
+
735
+ const suite = new DataProductTestSuite();
736
+ suite.id = suiteName;
737
+
738
+ // Try to infer columns from the access-point lambda
739
+ let inferredColumns: string[] = [];
740
+ try {
741
+ const cols = (yield inferDataProductItemColumns(
742
+ this.editorStore,
743
+ dp,
744
+ accessPointId,
745
+ )) as string[] | undefined;
746
+ if (cols) {
747
+ inferredColumns = cols;
748
+ }
749
+ } catch {
750
+ // Column inference is best-effort; continue with empty columns
751
+ }
752
+
753
+ // Resolve INPUT sources via the graph (follows function calls, avoids self-refs)
754
+ const accessPointForSuite = dp.accessPointGroups
755
+ .flatMap((g) => g.accessPoints)
756
+ .find((ap) => ap.id === accessPointId);
757
+ const rawLambdaForSuite = accessPointForSuite
758
+ ? getAccessPointLambda(accessPointForSuite)
759
+ : undefined;
760
+
761
+ suite.testData = [];
762
+ if (rawLambdaForSuite) {
763
+ const all =
764
+ (yield this.editorStore.graphManagerState.graphManager.collectAccessorsInRawLambda(
765
+ rawLambdaForSuite,
766
+ this.editorStore.graphManagerState.graph,
767
+ )) as Accessor[];
768
+ const externalAccessors = all.filter(
769
+ (accessor) =>
770
+ isIngestOrDataProductAccessor(accessor) &&
771
+ accessor.parentElement.path !== dp.path,
772
+ );
773
+ const byElement = new Map<string, Accessor[]>();
774
+ for (const acc of externalAccessors) {
775
+ const grp = byElement.get(acc.parentElement.path) ?? [];
776
+ grp.push(acc);
777
+ byElement.set(acc.parentElement.path, grp);
778
+ }
779
+ for (const [elementPath, accs] of byElement) {
780
+ const element =
781
+ this.editorStore.graphManagerState.graph.getNullableElement(
782
+ elementPath,
783
+ );
784
+ if (!element) {
785
+ continue;
786
+ }
787
+ const resolver = new BaseDataResolver();
788
+ resolver.element = PackageableElementExplicitReference.create(element);
789
+ const relData = buildRelationElementsDataWithColumns(accs);
790
+ observe_RelationElementsData(relData);
791
+ resolver.data = relData;
792
+ suite.testData.push(resolver);
793
+ }
794
+ }
795
+ // Fallback: no external sources resolved — seed a single resolver on current DP
796
+ if (suite.testData.length === 0) {
797
+ const testData = new BaseDataResolver();
798
+ testData.element = PackageableElementExplicitReference.create(dp);
799
+ const relData = new RelationElementsData();
800
+ relData.relationElements = [
801
+ createEmptyRelationElement(accessPointId, inferredColumns),
802
+ ];
803
+ observe_RelationElementsData(relData);
804
+ testData.data = relData;
805
+ suite.testData = [testData];
806
+ }
807
+
808
+ // Create one initial test with EqualToRelation assertion
809
+ const test = new DataProductAccessPointTest();
810
+ test.id = testName;
811
+ test.__parent = suite;
812
+ test.accessPointId = accessPointId;
813
+
814
+ const assertion = new EqualToRelation();
815
+ assertion.id = 'assert_1';
816
+ const expectedRelElement = new RelationElement();
817
+ expectedRelElement.paths = [accessPointId];
818
+ expectedRelElement.columns = inferredColumns;
819
+ expectedRelElement.rows = [];
820
+ observe_RelationElement(expectedRelElement);
821
+ assertion.expected = expectedRelElement;
822
+ test.assertions = [assertion];
823
+
824
+ suite.tests = [test];
825
+
826
+ const observed = observe_DataProductTestSuite(suite, observerContext);
827
+ addUniqueEntry(dp.tests, observed);
828
+
829
+ const suiteState = new DataProductTestSuiteState(
830
+ this.editorStore,
831
+ this,
832
+ observed,
833
+ );
834
+ this.suiteStates.push(suiteState);
835
+ this.selectedSuiteState = suiteState;
836
+ return undefined;
837
+ }
838
+
839
+ deleteSuite(suiteState: DataProductTestSuiteState): void {
840
+ const dp = this.dataProduct;
841
+ deleteEntry(dp.tests, suiteState.suite);
842
+ deleteEntry(this.suiteStates, suiteState);
843
+ if (this.selectedSuiteState === suiteState) {
844
+ this.selectedSuiteState = this.suiteStates[0];
845
+ }
846
+ }
847
+
848
+ *runAllTests(): GeneratorFn<void> {
849
+ try {
850
+ this.runningAllTestsState.inProgress();
851
+ for (const suiteState of this.suiteStates) {
852
+ if (suiteState.suite.tests.length > 0) {
853
+ yield flowResult(suiteState.runSuite());
854
+ }
855
+ }
856
+ this.runningAllTestsState.complete();
857
+ } catch (error) {
858
+ assertErrorThrown(error);
859
+ this.editorStore.applicationStore.notificationService.notifyError(error);
860
+ this.runningAllTestsState.fail();
861
+ }
862
+ }
863
+ }