@elisra-devops/docgen-data-provider 1.74.0 → 1.76.0

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.
@@ -2,7 +2,31 @@ import DataProviderUtils from '../utils/DataProviderUtils';
2
2
  import { TFSServices } from '../helpers/tfs';
3
3
  import { OpenPcrRequest, PlainTestResult, TestSteps } from '../models/tfs-data';
4
4
  import { AdoWorkItemComment, AdoWorkItemCommentsResponse } from '../models/ado-comments';
5
+ import type {
6
+ MewpBugLink,
7
+ MewpCoverageBugCell,
8
+ MewpCoverageFlatPayload,
9
+ MewpExternalFilesValidationResponse,
10
+ MewpCoverageL3L4Cell,
11
+ MewpCoverageRequestOptions,
12
+ MewpExternalFileRef,
13
+ MewpExternalTableValidationResult,
14
+ MewpCoverageRow,
15
+ MewpInternalValidationFlatPayload,
16
+ MewpInternalValidationRequestOptions,
17
+ MewpInternalValidationRow,
18
+ MewpL2RequirementFamily,
19
+ MewpL2RequirementWorkItem,
20
+ MewpL3L4Link,
21
+ MewpLinkedRequirementsByTestCase,
22
+ MewpRequirementIndex,
23
+ MewpRunStatus,
24
+ } from '../models/mewp-reporting';
5
25
  import logger from '../utils/logger';
26
+ import MewpExternalIngestionUtils from '../utils/mewpExternalIngestionUtils';
27
+ import MewpExternalTableUtils, {
28
+ MewpExternalFileValidationError,
29
+ } from '../utils/mewpExternalTableUtils';
6
30
  import Utils from '../utils/testStepParserHelper';
7
31
  import TicketsDataProvider from './TicketsDataProvider';
8
32
  const pLimit = require('p-limit');
@@ -30,14 +54,24 @@ const pLimit = require('p-limit');
30
54
  */
31
55
  export default class ResultDataProvider {
32
56
  private static readonly MEWP_L2_COVERAGE_COLUMNS = [
33
- 'Customer ID',
34
- 'Title (Customer name)',
35
- 'Responsibility - SAPWBS (ESUK/IL)',
36
- 'Test case id',
37
- 'Test case title',
38
- 'Number of passed steps',
39
- 'Number of failed steps',
40
- 'Number of not run tests',
57
+ 'L2 REQ ID',
58
+ 'L2 REQ Title',
59
+ 'L2 SubSystem',
60
+ 'L2 Run Status',
61
+ 'Bug ID',
62
+ 'Bug Title',
63
+ 'Bug Responsibility',
64
+ 'L3 REQ ID',
65
+ 'L3 REQ Title',
66
+ 'L4 REQ ID',
67
+ 'L4 REQ Title',
68
+ ];
69
+ private static readonly INTERNAL_VALIDATION_COLUMNS = [
70
+ 'Test Case ID',
71
+ 'Test Case Title',
72
+ 'Mentioned but Not Linked',
73
+ 'Linked but Not Mentioned',
74
+ 'Validation Status',
41
75
  ];
42
76
 
43
77
  orgUrl: string = '';
@@ -47,6 +81,8 @@ export default class ResultDataProvider {
47
81
  private testToAssociatedItemMap: Map<number, Set<any>>;
48
82
  private querySelectedColumns: any[];
49
83
  private workItemDiscussionCache: Map<number, any[]>;
84
+ private mewpExternalTableUtils: MewpExternalTableUtils;
85
+ private mewpExternalIngestionUtils: MewpExternalIngestionUtils;
50
86
  constructor(orgUrl: string, token: string) {
51
87
  this.orgUrl = orgUrl;
52
88
  this.token = token;
@@ -54,6 +90,8 @@ export default class ResultDataProvider {
54
90
  this.testToAssociatedItemMap = new Map<number, Set<any>>();
55
91
  this.querySelectedColumns = [];
56
92
  this.workItemDiscussionCache = new Map<number, any[]>();
93
+ this.mewpExternalTableUtils = new MewpExternalTableUtils();
94
+ this.mewpExternalIngestionUtils = new MewpExternalIngestionUtils(this.mewpExternalTableUtils);
57
95
  }
58
96
 
59
97
  /**
@@ -394,20 +432,52 @@ export default class ResultDataProvider {
394
432
  testPlanId: string,
395
433
  projectName: string,
396
434
  selectedSuiteIds: number[] | undefined,
397
- linkedQueryRequest?: any
398
- ) {
399
- const defaultPayload = {
435
+ linkedQueryRequest?: any,
436
+ options?: MewpCoverageRequestOptions
437
+ ): Promise<MewpCoverageFlatPayload> {
438
+ const defaultPayload: MewpCoverageFlatPayload = {
400
439
  sheetName: `MEWP L2 Coverage - Plan ${testPlanId}`,
401
440
  columnOrder: [...ResultDataProvider.MEWP_L2_COVERAGE_COLUMNS],
402
- rows: [] as any[],
441
+ rows: [],
403
442
  };
404
443
 
405
444
  try {
406
445
  const planName = await this.fetchTestPlanName(testPlanId, projectName);
407
- const suites = await this.fetchTestSuites(testPlanId, projectName, selectedSuiteIds, true);
408
- const testData = await this.fetchTestData(suites, projectName, testPlanId, false);
446
+ const testData = await this.fetchMewpScopedTestData(
447
+ testPlanId,
448
+ projectName,
449
+ selectedSuiteIds,
450
+ !!options?.useRelFallback
451
+ );
409
452
 
410
- const requirements = await this.fetchMewpL2Requirements(projectName, linkedQueryRequest);
453
+ const allRequirements = await this.fetchMewpL2Requirements(projectName);
454
+ if (allRequirements.length === 0) {
455
+ return {
456
+ ...defaultPayload,
457
+ sheetName: this.buildMewpCoverageSheetName(planName, testPlanId),
458
+ };
459
+ }
460
+
461
+ const linkedRequirementsByTestCase = await this.buildLinkedRequirementsByTestCase(
462
+ allRequirements,
463
+ testData,
464
+ projectName
465
+ );
466
+ const scopedRequirementKeys = await this.resolveMewpRequirementScopeKeysFromQuery(
467
+ linkedQueryRequest,
468
+ allRequirements,
469
+ linkedRequirementsByTestCase
470
+ );
471
+ const requirements = this.collapseMewpRequirementFamilies(
472
+ allRequirements,
473
+ scopedRequirementKeys?.size ? scopedRequirementKeys : undefined
474
+ );
475
+ const requirementSapWbsByBaseKey = this.buildRequirementSapWbsByBaseKey(allRequirements);
476
+ const externalBugsByTestCase = await this.loadExternalBugsByTestCase(options?.externalBugsFile);
477
+ const externalL3L4ByBaseKey = await this.loadExternalL3L4ByBaseKey(
478
+ options?.externalL3L4File,
479
+ requirementSapWbsByBaseKey
480
+ );
411
481
  if (requirements.length === 0) {
412
482
  return {
413
483
  ...defaultPayload,
@@ -415,47 +485,40 @@ export default class ResultDataProvider {
415
485
  };
416
486
  }
417
487
 
418
- const requirementIndex = new Map<string, Map<number, { passed: number; failed: number; notRun: number }>>();
488
+ const requirementIndex: MewpRequirementIndex = new Map();
419
489
  const observedTestCaseIdsByRequirement = new Map<string, Set<number>>();
420
490
  const requirementKeys = new Set<string>();
421
491
  requirements.forEach((requirement) => {
422
- const key = this.toRequirementKey(requirement.requirementId);
492
+ const key = String(requirement?.baseKey || '').trim();
423
493
  if (!key) return;
424
494
  requirementKeys.add(key);
425
495
  });
426
496
 
427
497
  const parsedDefinitionStepsByTestCase = new Map<number, TestSteps[]>();
428
498
  const testCaseStepsXmlMap = this.buildTestCaseStepsXmlMap(testData);
429
- const testCaseTitleMap = this.buildMewpTestCaseTitleMap(testData);
430
-
431
499
  const runResults = await this.fetchAllResultDataTestReporter(testData, projectName, [], false, false);
432
500
  for (const runResult of runResults) {
433
501
  const testCaseId = this.extractMewpTestCaseId(runResult);
434
- const runTestCaseTitle = this.toMewpComparableText(
435
- runResult?.testCase?.name || runResult?.testCaseName || runResult?.testCaseTitle
436
- );
437
- if (Number.isFinite(testCaseId) && testCaseId > 0 && runTestCaseTitle && !testCaseTitleMap.has(testCaseId)) {
438
- testCaseTitleMap.set(testCaseId, runTestCaseTitle);
439
- }
440
- const actionResults = Array.isArray(runResult?.iteration?.actionResults)
441
- ? runResult.iteration.actionResults
502
+ const rawActionResults = Array.isArray(runResult?.iteration?.actionResults)
503
+ ? runResult.iteration.actionResults.filter((item: any) => !item?.isSharedStepTitle)
442
504
  : [];
505
+ const actionResults = rawActionResults.sort((a: any, b: any) =>
506
+ this.compareActionResults(
507
+ String(a?.stepPosition || a?.stepIdentifier || ''),
508
+ String(b?.stepPosition || b?.stepIdentifier || '')
509
+ )
510
+ );
443
511
  const hasExecutedRun =
444
512
  Number(runResult?.lastRunId || 0) > 0 && Number(runResult?.lastResultId || 0) > 0;
445
513
 
446
514
  if (actionResults.length > 0) {
447
- for (const actionResult of actionResults) {
448
- if (actionResult?.isSharedStepTitle) continue;
449
- const stepStatus = this.classifyRequirementStepOutcome(actionResult?.outcome);
450
- this.accumulateRequirementCountsFromStepText(
451
- `${String(actionResult?.action || '')} ${String(actionResult?.expected || '')}`,
452
- stepStatus,
453
- testCaseId,
454
- requirementKeys,
455
- requirementIndex,
456
- observedTestCaseIdsByRequirement
457
- );
458
- }
515
+ this.accumulateRequirementCountsFromActionResults(
516
+ actionResults,
517
+ testCaseId,
518
+ requirementKeys,
519
+ requirementIndex,
520
+ observedTestCaseIdsByRequirement
521
+ );
459
522
  continue;
460
523
  }
461
524
 
@@ -476,24 +539,33 @@ export default class ResultDataProvider {
476
539
  }
477
540
 
478
541
  const definitionSteps = parsedDefinitionStepsByTestCase.get(testCaseId) || [];
479
- for (const step of definitionSteps) {
480
- if (step?.isSharedStepTitle) continue;
481
- this.accumulateRequirementCountsFromStepText(
482
- `${String(step?.action || '')} ${String(step?.expected || '')}`,
483
- 'notRun',
484
- testCaseId,
485
- requirementKeys,
486
- requirementIndex,
487
- observedTestCaseIdsByRequirement
488
- );
489
- }
542
+ const fallbackActionResults = definitionSteps
543
+ .filter((step) => !step?.isSharedStepTitle)
544
+ .sort((a, b) =>
545
+ this.compareActionResults(String(a?.stepPosition || ''), String(b?.stepPosition || ''))
546
+ )
547
+ .map((step) => ({
548
+ stepPosition: step?.stepPosition,
549
+ expected: step?.expected,
550
+ outcome: 'Unspecified',
551
+ }));
552
+
553
+ this.accumulateRequirementCountsFromActionResults(
554
+ fallbackActionResults,
555
+ testCaseId,
556
+ requirementKeys,
557
+ requirementIndex,
558
+ observedTestCaseIdsByRequirement
559
+ );
490
560
  }
491
561
 
492
562
  const rows = this.buildMewpCoverageRows(
493
563
  requirements,
494
564
  requirementIndex,
495
565
  observedTestCaseIdsByRequirement,
496
- testCaseTitleMap
566
+ linkedRequirementsByTestCase,
567
+ externalL3L4ByBaseKey,
568
+ externalBugsByTestCase
497
569
  );
498
570
 
499
571
  return {
@@ -503,10 +575,261 @@ export default class ResultDataProvider {
503
575
  };
504
576
  } catch (error: any) {
505
577
  logger.error(`Error during getMewpL2CoverageFlatResults: ${error.message}`);
578
+ if (error instanceof MewpExternalFileValidationError) {
579
+ throw error;
580
+ }
506
581
  return defaultPayload;
507
582
  }
508
583
  }
509
584
 
585
+ public async getMewpInternalValidationFlatResults(
586
+ testPlanId: string,
587
+ projectName: string,
588
+ selectedSuiteIds: number[] | undefined,
589
+ linkedQueryRequest?: any,
590
+ options?: MewpInternalValidationRequestOptions
591
+ ): Promise<MewpInternalValidationFlatPayload> {
592
+ const defaultPayload: MewpInternalValidationFlatPayload = {
593
+ sheetName: `MEWP Internal Validation - Plan ${testPlanId}`,
594
+ columnOrder: [...ResultDataProvider.INTERNAL_VALIDATION_COLUMNS],
595
+ rows: [],
596
+ };
597
+
598
+ try {
599
+ const planName = await this.fetchTestPlanName(testPlanId, projectName);
600
+ const testData = await this.fetchMewpScopedTestData(
601
+ testPlanId,
602
+ projectName,
603
+ selectedSuiteIds,
604
+ !!options?.useRelFallback
605
+ );
606
+ const allRequirements = await this.fetchMewpL2Requirements(projectName);
607
+ const linkedRequirementsByTestCase = await this.buildLinkedRequirementsByTestCase(
608
+ allRequirements,
609
+ testData,
610
+ projectName
611
+ );
612
+ const scopedRequirementKeys = await this.resolveMewpRequirementScopeKeysFromQuery(
613
+ linkedQueryRequest,
614
+ allRequirements,
615
+ linkedRequirementsByTestCase
616
+ );
617
+ const requirementFamilies = this.buildRequirementFamilyMap(
618
+ allRequirements,
619
+ scopedRequirementKeys?.size ? scopedRequirementKeys : undefined
620
+ );
621
+
622
+ const rows: MewpInternalValidationRow[] = [];
623
+ const stepsXmlByTestCase = this.buildTestCaseStepsXmlMap(testData);
624
+ const testCaseTitleMap = this.buildMewpTestCaseTitleMap(testData);
625
+ const allTestCaseIds = new Set<number>();
626
+ for (const suite of testData || []) {
627
+ const testCasesItems = Array.isArray(suite?.testCasesItems) ? suite.testCasesItems : [];
628
+ for (const testCase of testCasesItems) {
629
+ const id = Number(testCase?.workItem?.id || testCase?.testCaseId || testCase?.id || 0);
630
+ if (Number.isFinite(id) && id > 0) allTestCaseIds.add(id);
631
+ }
632
+ const testPointsItems = Array.isArray(suite?.testPointsItems) ? suite.testPointsItems : [];
633
+ for (const testPoint of testPointsItems) {
634
+ const id = Number(testPoint?.testCaseId || testPoint?.testCase?.id || 0);
635
+ if (Number.isFinite(id) && id > 0) allTestCaseIds.add(id);
636
+ }
637
+ }
638
+
639
+ const validL2BaseKeys = new Set<string>([...requirementFamilies.keys()]);
640
+
641
+ for (const testCaseId of [...allTestCaseIds].sort((a, b) => a - b)) {
642
+ const stepsXml = stepsXmlByTestCase.get(testCaseId) || '';
643
+ const parsedSteps =
644
+ stepsXml && String(stepsXml).trim() !== ''
645
+ ? await this.testStepParserHelper.parseTestSteps(stepsXml, new Map<number, number>())
646
+ : [];
647
+ const mentionEntries = this.extractRequirementMentionsFromExpectedSteps(parsedSteps, true);
648
+ const mentionedL2Only = new Set<string>();
649
+ const mentionedCodeFirstStep = new Map<string, string>();
650
+ const mentionedBaseFirstStep = new Map<string, string>();
651
+ for (const mentionEntry of mentionEntries) {
652
+ const scopeFilteredCodes =
653
+ scopedRequirementKeys?.size && mentionEntry.codes.size > 0
654
+ ? [...mentionEntry.codes].filter((code) => scopedRequirementKeys.has(this.toRequirementKey(code)))
655
+ : [...mentionEntry.codes];
656
+ for (const code of scopeFilteredCodes) {
657
+ const baseKey = this.toRequirementKey(code);
658
+ if (!baseKey) continue;
659
+ if (validL2BaseKeys.has(baseKey)) {
660
+ mentionedL2Only.add(code);
661
+ if (!mentionedCodeFirstStep.has(code)) {
662
+ mentionedCodeFirstStep.set(code, mentionEntry.stepRef);
663
+ }
664
+ if (!mentionedBaseFirstStep.has(baseKey)) {
665
+ mentionedBaseFirstStep.set(baseKey, mentionEntry.stepRef);
666
+ }
667
+ }
668
+ }
669
+ }
670
+
671
+ const mentionedBaseKeys = new Set<string>(
672
+ [...mentionedL2Only].map((code) => this.toRequirementKey(code)).filter((code) => !!code)
673
+ );
674
+
675
+ const expectedFamilyCodes = new Set<string>();
676
+ for (const baseKey of mentionedBaseKeys) {
677
+ const familyCodes = requirementFamilies.get(baseKey);
678
+ if (familyCodes?.size) {
679
+ familyCodes.forEach((code) => expectedFamilyCodes.add(code));
680
+ } else {
681
+ for (const code of mentionedL2Only) {
682
+ if (this.toRequirementKey(code) === baseKey) expectedFamilyCodes.add(code);
683
+ }
684
+ }
685
+ }
686
+
687
+ const linkedFullCodesRaw = linkedRequirementsByTestCase.get(testCaseId)?.fullCodes || new Set<string>();
688
+ const linkedFullCodes =
689
+ scopedRequirementKeys?.size && linkedFullCodesRaw.size > 0
690
+ ? new Set<string>(
691
+ [...linkedFullCodesRaw].filter((code) =>
692
+ scopedRequirementKeys.has(this.toRequirementKey(code))
693
+ )
694
+ )
695
+ : linkedFullCodesRaw;
696
+ const linkedBaseKeys = new Set<string>(
697
+ [...linkedFullCodes].map((code) => this.toRequirementKey(code)).filter((code) => !!code)
698
+ );
699
+
700
+ const missingMentioned = [...mentionedL2Only].filter((code) => {
701
+ const baseKey = this.toRequirementKey(code);
702
+ if (!baseKey) return false;
703
+ const hasSpecificSuffix = /-\d+$/.test(code);
704
+ if (hasSpecificSuffix) return !linkedFullCodes.has(code);
705
+ return !linkedBaseKeys.has(baseKey);
706
+ });
707
+ const missingFamily = [...expectedFamilyCodes].filter((code) => !linkedFullCodes.has(code));
708
+ const extraLinked = [...linkedFullCodes].filter((code) => !expectedFamilyCodes.has(code));
709
+ const mentionedButNotLinkedByStep = new Map<string, Set<string>>();
710
+ const appendMentionedButNotLinked = (requirementId: string, stepRef: string) => {
711
+ const normalizedRequirementId = this.normalizeMewpRequirementCodeWithSuffix(requirementId);
712
+ if (!normalizedRequirementId) return;
713
+ const normalizedStepRef = String(stepRef || 'Step ?').trim() || 'Step ?';
714
+ if (!mentionedButNotLinkedByStep.has(normalizedStepRef)) {
715
+ mentionedButNotLinkedByStep.set(normalizedStepRef, new Set<string>());
716
+ }
717
+ mentionedButNotLinkedByStep.get(normalizedStepRef)!.add(normalizedRequirementId);
718
+ };
719
+
720
+ const sortedMissingMentioned = [...new Set(missingMentioned)].sort((a, b) => a.localeCompare(b));
721
+ const sortedMissingFamily = [...new Set(missingFamily)].sort((a, b) => a.localeCompare(b));
722
+ for (const code of sortedMissingMentioned) {
723
+ const stepRef = mentionedCodeFirstStep.get(code) || 'Step ?';
724
+ appendMentionedButNotLinked(code, stepRef);
725
+ }
726
+ for (const code of sortedMissingFamily) {
727
+ const baseKey = this.toRequirementKey(code);
728
+ const stepRef = mentionedBaseFirstStep.get(baseKey) || 'Step ?';
729
+ appendMentionedButNotLinked(code, stepRef);
730
+ }
731
+
732
+ const sortedExtraLinked = [...new Set(extraLinked)]
733
+ .map((code) => this.normalizeMewpRequirementCodeWithSuffix(code))
734
+ .filter((code) => !!code)
735
+ .sort((a, b) => a.localeCompare(b));
736
+
737
+ const parseStepOrder = (stepRef: string): number => {
738
+ const match = /step\s+(\d+)/i.exec(String(stepRef || ''));
739
+ const parsed = Number(match?.[1] || Number.POSITIVE_INFINITY);
740
+ return Number.isFinite(parsed) ? parsed : Number.POSITIVE_INFINITY;
741
+ };
742
+ const mentionedButNotLinked = [...mentionedButNotLinkedByStep.entries()]
743
+ .sort((a, b) => {
744
+ const stepOrderA = parseStepOrder(a[0]);
745
+ const stepOrderB = parseStepOrder(b[0]);
746
+ if (stepOrderA !== stepOrderB) return stepOrderA - stepOrderB;
747
+ return String(a[0]).localeCompare(String(b[0]));
748
+ })
749
+ .map(([stepRef, requirementIds]) => {
750
+ const requirementList = [...requirementIds].sort((a, b) => a.localeCompare(b));
751
+ return `${stepRef}: ${requirementList.join(', ')}`;
752
+ })
753
+ .join('; ');
754
+ const linkedButNotMentioned = sortedExtraLinked.join('; ');
755
+ const validationStatus: 'Pass' | 'Fail' =
756
+ mentionedButNotLinked || linkedButNotMentioned ? 'Fail' : 'Pass';
757
+
758
+ rows.push({
759
+ 'Test Case ID': testCaseId,
760
+ 'Test Case Title': String(testCaseTitleMap.get(testCaseId) || '').trim(),
761
+ 'Mentioned but Not Linked': mentionedButNotLinked,
762
+ 'Linked but Not Mentioned': linkedButNotMentioned,
763
+ 'Validation Status': validationStatus,
764
+ });
765
+ }
766
+
767
+ return {
768
+ sheetName: this.buildInternalValidationSheetName(planName, testPlanId),
769
+ columnOrder: [...ResultDataProvider.INTERNAL_VALIDATION_COLUMNS],
770
+ rows,
771
+ };
772
+ } catch (error: any) {
773
+ logger.error(`Error during getMewpInternalValidationFlatResults: ${error.message}`);
774
+ return defaultPayload;
775
+ }
776
+ }
777
+
778
+ public async validateMewpExternalFiles(options: {
779
+ externalBugsFile?: MewpExternalFileRef | null;
780
+ externalL3L4File?: MewpExternalFileRef | null;
781
+ }): Promise<MewpExternalFilesValidationResponse> {
782
+ const response: MewpExternalFilesValidationResponse = { valid: true };
783
+ const validateOne = async (
784
+ file: MewpExternalFileRef | null | undefined,
785
+ tableType: 'bugs' | 'l3l4'
786
+ ): Promise<MewpExternalTableValidationResult | undefined> => {
787
+ const sourceName = String(file?.name || file?.objectName || file?.text || file?.url || '').trim();
788
+ if (!sourceName) return undefined;
789
+
790
+ try {
791
+ const { rows, meta } = await this.mewpExternalTableUtils.loadExternalTableRowsWithMeta(
792
+ file,
793
+ tableType
794
+ );
795
+ return {
796
+ tableType,
797
+ sourceName: meta.sourceName,
798
+ valid: true,
799
+ headerRow: meta.headerRow,
800
+ matchedRequiredColumns: meta.matchedRequiredColumns,
801
+ totalRequiredColumns: meta.totalRequiredColumns,
802
+ missingRequiredColumns: [],
803
+ rowCount: rows.length,
804
+ message: 'File schema is valid',
805
+ };
806
+ } catch (error: any) {
807
+ if (error instanceof MewpExternalFileValidationError) {
808
+ return {
809
+ ...error.details,
810
+ valid: false,
811
+ };
812
+ }
813
+ return {
814
+ tableType,
815
+ sourceName: sourceName || tableType,
816
+ valid: false,
817
+ headerRow: '',
818
+ matchedRequiredColumns: 0,
819
+ totalRequiredColumns: this.mewpExternalTableUtils.getRequiredColumnCount(tableType),
820
+ missingRequiredColumns: [],
821
+ rowCount: 0,
822
+ message: String(error?.message || error || 'Unknown validation error'),
823
+ };
824
+ }
825
+ };
826
+
827
+ response.bugs = await validateOne(options?.externalBugsFile, 'bugs');
828
+ response.l3l4 = await validateOne(options?.externalL3L4File, 'l3l4');
829
+ response.valid = [response.bugs, response.l3l4].filter(Boolean).every((item) => !!item?.valid);
830
+ return response;
831
+ }
832
+
510
833
  /**
511
834
  * Mapping each attachment to a proper URL for downloading it
512
835
  * @param runResults Array of run results
@@ -550,79 +873,176 @@ export default class ResultDataProvider {
550
873
  return `MEWP L2 Coverage - ${suffix}`;
551
874
  }
552
875
 
876
+ private buildInternalValidationSheetName(planName: string, testPlanId: string): string {
877
+ const suffix = String(planName || '').trim() || `Plan ${testPlanId}`;
878
+ return `MEWP Internal Validation - ${suffix}`;
879
+ }
880
+
553
881
  private createMewpCoverageRow(
554
- requirement: {
555
- requirementId: string;
556
- title: string;
557
- responsibility: string;
558
- },
559
- testCaseId: number | undefined,
560
- testCaseTitle: string,
561
- stepSummary: { passed: number; failed: number; notRun: number }
562
- ) {
563
- const customerId = String(requirement.requirementId || '').trim();
564
- const customerTitle = String(requirement.title || '').trim();
565
- const responsibility = String(requirement.responsibility || '').trim();
566
- const safeTestCaseId = Number.isFinite(testCaseId) && Number(testCaseId) > 0 ? Number(testCaseId) : '';
882
+ requirement: Pick<MewpL2RequirementFamily, 'requirementId' | 'title' | 'subSystem' | 'responsibility'>,
883
+ runStatus: MewpRunStatus,
884
+ bug: MewpCoverageBugCell,
885
+ linkedL3L4: MewpCoverageL3L4Cell
886
+ ): MewpCoverageRow {
887
+ const l2ReqId = this.formatMewpCustomerId(requirement.requirementId);
888
+ const l2ReqTitle = this.toMewpComparableText(requirement.title);
889
+ const l2SubSystem = this.toMewpComparableText(requirement.subSystem);
567
890
 
568
891
  return {
569
- 'Customer ID': customerId,
570
- 'Title (Customer name)': customerTitle,
571
- 'Responsibility - SAPWBS (ESUK/IL)': responsibility,
572
- 'Test case id': safeTestCaseId,
573
- 'Test case title': String(testCaseTitle || '').trim(),
574
- 'Number of passed steps': Number.isFinite(stepSummary?.passed) ? stepSummary.passed : 0,
575
- 'Number of failed steps': Number.isFinite(stepSummary?.failed) ? stepSummary.failed : 0,
576
- 'Number of not run tests': Number.isFinite(stepSummary?.notRun) ? stepSummary.notRun : 0,
892
+ 'L2 REQ ID': l2ReqId,
893
+ 'L2 REQ Title': l2ReqTitle,
894
+ 'L2 SubSystem': l2SubSystem,
895
+ 'L2 Run Status': runStatus,
896
+ 'Bug ID': Number.isFinite(Number(bug?.id)) && Number(bug?.id) > 0 ? Number(bug?.id) : '',
897
+ 'Bug Title': String(bug?.title || '').trim(),
898
+ 'Bug Responsibility': String(bug?.responsibility || '').trim(),
899
+ 'L3 REQ ID': String(linkedL3L4?.l3Id || '').trim(),
900
+ 'L3 REQ Title': String(linkedL3L4?.l3Title || '').trim(),
901
+ 'L4 REQ ID': String(linkedL3L4?.l4Id || '').trim(),
902
+ 'L4 REQ Title': String(linkedL3L4?.l4Title || '').trim(),
577
903
  };
578
904
  }
579
905
 
906
+ private createEmptyMewpCoverageBugCell(): MewpCoverageBugCell {
907
+ return { id: '' as '', title: '', responsibility: '' };
908
+ }
909
+
910
+ private createEmptyMewpCoverageL3L4Cell(): MewpCoverageL3L4Cell {
911
+ return { l3Id: '', l3Title: '', l4Id: '', l4Title: '' };
912
+ }
913
+
914
+ private buildMewpCoverageL3L4Rows(links: MewpL3L4Link[]): MewpCoverageL3L4Cell[] {
915
+ const sorted = [...(links || [])].sort((a, b) => {
916
+ if (a.level !== b.level) return a.level === 'L3' ? -1 : 1;
917
+ return String(a.id || '').localeCompare(String(b.id || ''));
918
+ });
919
+
920
+ const rows: MewpCoverageL3L4Cell[] = [];
921
+ for (const item of sorted) {
922
+ const isL3 = item.level === 'L3';
923
+ rows.push({
924
+ l3Id: isL3 ? String(item?.id || '').trim() : '',
925
+ l3Title: isL3 ? String(item?.title || '').trim() : '',
926
+ l4Id: isL3 ? '' : String(item?.id || '').trim(),
927
+ l4Title: isL3 ? '' : String(item?.title || '').trim(),
928
+ });
929
+ }
930
+ return rows;
931
+ }
932
+
933
+ private formatMewpCustomerId(rawValue: string): string {
934
+ const normalized = this.normalizeMewpRequirementCode(this.toMewpComparableText(rawValue));
935
+ if (normalized) return normalized;
936
+
937
+ const onlyDigits = String(rawValue || '').replace(/\D/g, '');
938
+ if (onlyDigits) return `SR${onlyDigits}`;
939
+ return '';
940
+ }
941
+
580
942
  private buildMewpCoverageRows(
581
- requirements: Array<{
582
- requirementId: string;
583
- title: string;
584
- responsibility: string;
585
- linkedTestCaseIds: number[];
586
- }>,
587
- requirementIndex: Map<string, Map<number, { passed: number; failed: number; notRun: number }>>,
943
+ requirements: MewpL2RequirementFamily[],
944
+ requirementIndex: MewpRequirementIndex,
588
945
  observedTestCaseIdsByRequirement: Map<string, Set<number>>,
589
- testCaseTitleMap: Map<number, string>
590
- ): any[] {
591
- const rows: any[] = [];
946
+ linkedRequirementsByTestCase: MewpLinkedRequirementsByTestCase,
947
+ l3l4ByBaseKey: Map<string, MewpL3L4Link[]>,
948
+ externalBugsByTestCase: Map<number, MewpBugLink[]>
949
+ ): MewpCoverageRow[] {
950
+ const rows: MewpCoverageRow[] = [];
951
+ const linkedByRequirement = this.invertBaseRequirementLinks(linkedRequirementsByTestCase);
592
952
  for (const requirement of requirements) {
593
- const key = this.toRequirementKey(requirement.requirementId);
953
+ const key = String(requirement?.baseKey || this.toRequirementKey(requirement.requirementId) || '').trim();
594
954
  const linkedTestCaseIds = (requirement?.linkedTestCaseIds || []).filter(
595
955
  (id) => Number.isFinite(id) && Number(id) > 0
596
956
  );
957
+ const linkedByTestCase = key ? Array.from(linkedByRequirement.get(key) || []) : [];
597
958
  const observedTestCaseIds = key
598
959
  ? Array.from(observedTestCaseIdsByRequirement.get(key) || [])
599
960
  : [];
600
961
 
601
- const testCaseIds = Array.from(new Set<number>([...linkedTestCaseIds, ...observedTestCaseIds])).sort(
602
- (a, b) => a - b
603
- );
962
+ const testCaseIds = Array.from(
963
+ new Set<number>([...linkedTestCaseIds, ...linkedByTestCase, ...observedTestCaseIds])
964
+ ).sort((a, b) => a - b);
604
965
 
605
- if (testCaseIds.length === 0) {
606
- rows.push(
607
- this.createMewpCoverageRow(requirement, undefined, '', {
608
- passed: 0,
609
- failed: 0,
610
- notRun: 0,
611
- })
612
- );
613
- continue;
614
- }
966
+ let totalPassed = 0;
967
+ let totalFailed = 0;
968
+ let totalNotRun = 0;
969
+ const aggregatedBugs = new Map<number, MewpBugLink>();
615
970
 
616
971
  for (const testCaseId of testCaseIds) {
617
972
  const summary = key
618
973
  ? requirementIndex.get(key)?.get(testCaseId) || { passed: 0, failed: 0, notRun: 0 }
619
974
  : { passed: 0, failed: 0, notRun: 0 };
975
+ totalPassed += summary.passed;
976
+ totalFailed += summary.failed;
977
+ totalNotRun += summary.notRun;
978
+
979
+ if (summary.failed > 0) {
980
+ const externalBugs = externalBugsByTestCase.get(testCaseId) || [];
981
+ for (const bug of externalBugs) {
982
+ const bugBaseKey = String(bug?.requirementBaseKey || '').trim();
983
+ if (bugBaseKey && bugBaseKey !== key) continue;
984
+ const bugId = Number(bug?.id || 0);
985
+ if (!Number.isFinite(bugId) || bugId <= 0) continue;
986
+ aggregatedBugs.set(bugId, {
987
+ ...bug,
988
+ responsibility: this.resolveCoverageBugResponsibility(
989
+ String(bug?.responsibility || ''),
990
+ requirement
991
+ ),
992
+ });
993
+ }
994
+ }
995
+ }
996
+
997
+ const runStatus = this.resolveMewpL2RunStatus({
998
+ passed: totalPassed,
999
+ failed: totalFailed,
1000
+ notRun: totalNotRun,
1001
+ hasAnyTestCase: testCaseIds.length > 0,
1002
+ });
1003
+
1004
+ const bugsForRows =
1005
+ runStatus === 'Fail'
1006
+ ? Array.from(aggregatedBugs.values()).sort((a, b) => a.id - b.id)
1007
+ : [];
1008
+ const l3l4ForRows = [...(l3l4ByBaseKey.get(key) || [])];
1009
+
1010
+ const bugRows: MewpCoverageBugCell[] =
1011
+ bugsForRows.length > 0
1012
+ ? bugsForRows
1013
+ : [];
1014
+ const l3l4Rows: MewpCoverageL3L4Cell[] = this.buildMewpCoverageL3L4Rows(l3l4ForRows);
1015
+
1016
+ if (bugRows.length === 0 && l3l4Rows.length === 0) {
620
1017
  rows.push(
621
1018
  this.createMewpCoverageRow(
622
1019
  requirement,
623
- testCaseId,
624
- String(testCaseTitleMap.get(testCaseId) || ''),
625
- summary
1020
+ runStatus,
1021
+ this.createEmptyMewpCoverageBugCell(),
1022
+ this.createEmptyMewpCoverageL3L4Cell()
1023
+ )
1024
+ );
1025
+ continue;
1026
+ }
1027
+
1028
+ for (const bug of bugRows) {
1029
+ rows.push(
1030
+ this.createMewpCoverageRow(
1031
+ requirement,
1032
+ runStatus,
1033
+ bug,
1034
+ this.createEmptyMewpCoverageL3L4Cell()
1035
+ )
1036
+ );
1037
+ }
1038
+
1039
+ for (const linkedL3L4 of l3l4Rows) {
1040
+ rows.push(
1041
+ this.createMewpCoverageRow(
1042
+ requirement,
1043
+ runStatus,
1044
+ this.createEmptyMewpCoverageBugCell(),
1045
+ linkedL3L4
626
1046
  )
627
1047
  );
628
1048
  }
@@ -631,6 +1051,262 @@ export default class ResultDataProvider {
631
1051
  return rows;
632
1052
  }
633
1053
 
1054
+ private resolveCoverageBugResponsibility(
1055
+ rawResponsibility: string,
1056
+ requirement: Pick<MewpL2RequirementFamily, 'responsibility'>
1057
+ ): string {
1058
+ const direct = String(rawResponsibility || '').trim();
1059
+ if (direct && direct.toLowerCase() !== 'unknown') return direct;
1060
+
1061
+ const requirementResponsibility = String(requirement?.responsibility || '')
1062
+ .trim()
1063
+ .toUpperCase();
1064
+ if (requirementResponsibility === 'ESUK') return 'ESUK';
1065
+ if (requirementResponsibility === 'IL' || requirementResponsibility === 'ELISRA') return 'Elisra';
1066
+
1067
+ return direct || 'Unknown';
1068
+ }
1069
+
1070
+ private resolveMewpL2RunStatus(input: {
1071
+ passed: number;
1072
+ failed: number;
1073
+ notRun: number;
1074
+ hasAnyTestCase: boolean;
1075
+ }): MewpRunStatus {
1076
+ if ((input?.failed || 0) > 0) return 'Fail';
1077
+ if ((input?.notRun || 0) > 0) return 'Not Run';
1078
+ if ((input?.passed || 0) > 0) return 'Pass';
1079
+ return input?.hasAnyTestCase ? 'Not Run' : 'Not Run';
1080
+ }
1081
+
1082
+ private async fetchMewpScopedTestData(
1083
+ testPlanId: string,
1084
+ projectName: string,
1085
+ selectedSuiteIds: number[] | undefined,
1086
+ useRelFallback: boolean
1087
+ ): Promise<any[]> {
1088
+ if (!useRelFallback) {
1089
+ const suites = await this.fetchTestSuites(testPlanId, projectName, selectedSuiteIds, true);
1090
+ return this.fetchTestData(suites, projectName, testPlanId, false);
1091
+ }
1092
+
1093
+ const selectedSuites = await this.fetchTestSuites(testPlanId, projectName, selectedSuiteIds, true);
1094
+ const selectedRel = this.resolveMaxRelNumberFromSuites(selectedSuites);
1095
+ if (selectedRel <= 0) {
1096
+ return this.fetchTestData(selectedSuites, projectName, testPlanId, false);
1097
+ }
1098
+
1099
+ const allSuites = await this.fetchTestSuites(testPlanId, projectName, undefined, true);
1100
+ const relScopedSuites = allSuites.filter((suite) => {
1101
+ const rel = this.extractRelNumberFromSuite(suite);
1102
+ return rel > 0 && rel <= selectedRel;
1103
+ });
1104
+ const suitesForFetch = relScopedSuites.length > 0 ? relScopedSuites : selectedSuites;
1105
+ const rawTestData = await this.fetchTestData(suitesForFetch, projectName, testPlanId, false);
1106
+ return this.reduceToLatestRelRunPerTestCase(rawTestData);
1107
+ }
1108
+
1109
+ private extractRelNumberFromSuite(suite: any): number {
1110
+ const candidates = [
1111
+ suite?.suiteName,
1112
+ suite?.parentSuiteName,
1113
+ suite?.suitePath,
1114
+ suite?.testGroupName,
1115
+ ];
1116
+ const pattern = /(?:^|[^a-z0-9])rel\s*([0-9]+)/i;
1117
+ for (const item of candidates) {
1118
+ const match = pattern.exec(String(item || ''));
1119
+ if (!match) continue;
1120
+ const parsed = Number(match[1]);
1121
+ if (Number.isFinite(parsed) && parsed > 0) {
1122
+ return parsed;
1123
+ }
1124
+ }
1125
+ return 0;
1126
+ }
1127
+
1128
+ private resolveMaxRelNumberFromSuites(suites: any[]): number {
1129
+ let maxRel = 0;
1130
+ for (const suite of suites || []) {
1131
+ const rel = this.extractRelNumberFromSuite(suite);
1132
+ if (rel > maxRel) maxRel = rel;
1133
+ }
1134
+ return maxRel;
1135
+ }
1136
+
1137
+ private reduceToLatestRelRunPerTestCase(testData: any[]): any[] {
1138
+ type Candidate = {
1139
+ point: any;
1140
+ rel: number;
1141
+ runId: number;
1142
+ resultId: number;
1143
+ hasRun: boolean;
1144
+ };
1145
+
1146
+ const candidatesByTestCase = new Map<number, Candidate[]>();
1147
+ const testCaseDefinitionById = new Map<number, any>();
1148
+
1149
+ for (const suite of testData || []) {
1150
+ const rel = this.extractRelNumberFromSuite(suite);
1151
+ const testPointsItems = Array.isArray(suite?.testPointsItems) ? suite.testPointsItems : [];
1152
+ const testCasesItems = Array.isArray(suite?.testCasesItems) ? suite.testCasesItems : [];
1153
+
1154
+ for (const testCase of testCasesItems) {
1155
+ const testCaseId = Number(testCase?.workItem?.id || testCase?.testCaseId || testCase?.id || 0);
1156
+ if (!Number.isFinite(testCaseId) || testCaseId <= 0) continue;
1157
+ if (!testCaseDefinitionById.has(testCaseId)) {
1158
+ testCaseDefinitionById.set(testCaseId, testCase);
1159
+ }
1160
+ }
1161
+
1162
+ for (const point of testPointsItems) {
1163
+ const testCaseId = Number(point?.testCaseId || point?.testCase?.id || 0);
1164
+ if (!Number.isFinite(testCaseId) || testCaseId <= 0) continue;
1165
+
1166
+ const runId = Number(point?.lastRunId || 0);
1167
+ const resultId = Number(point?.lastResultId || 0);
1168
+ const hasRun = runId > 0 && resultId > 0;
1169
+ if (!candidatesByTestCase.has(testCaseId)) {
1170
+ candidatesByTestCase.set(testCaseId, []);
1171
+ }
1172
+ candidatesByTestCase.get(testCaseId)!.push({
1173
+ point,
1174
+ rel,
1175
+ runId,
1176
+ resultId,
1177
+ hasRun,
1178
+ });
1179
+ }
1180
+ }
1181
+
1182
+ const selectedPoints: any[] = [];
1183
+ const selectedTestCaseIds = new Set<number>();
1184
+ for (const [testCaseId, candidates] of candidatesByTestCase.entries()) {
1185
+ const sorted = [...candidates].sort((a, b) => {
1186
+ if (a.hasRun !== b.hasRun) return a.hasRun ? -1 : 1;
1187
+ if (a.rel !== b.rel) return b.rel - a.rel;
1188
+ if (a.runId !== b.runId) return b.runId - a.runId;
1189
+ return b.resultId - a.resultId;
1190
+ });
1191
+ const chosen = sorted[0];
1192
+ if (!chosen?.point) continue;
1193
+ selectedPoints.push(chosen.point);
1194
+ selectedTestCaseIds.add(testCaseId);
1195
+ }
1196
+
1197
+ const selectedTestCases: any[] = [];
1198
+ for (const testCaseId of selectedTestCaseIds) {
1199
+ const definition = testCaseDefinitionById.get(testCaseId);
1200
+ if (definition) {
1201
+ selectedTestCases.push(definition);
1202
+ }
1203
+ }
1204
+
1205
+ return [
1206
+ {
1207
+ testSuiteId: 'MEWP_REL_SCOPED',
1208
+ suiteId: 'MEWP_REL_SCOPED',
1209
+ suiteName: 'MEWP Rel Scoped',
1210
+ parentSuiteId: '',
1211
+ parentSuiteName: '',
1212
+ suitePath: 'MEWP Rel Scoped',
1213
+ testGroupName: 'MEWP Rel Scoped',
1214
+ testPointsItems: selectedPoints,
1215
+ testCasesItems: selectedTestCases,
1216
+ },
1217
+ ];
1218
+ }
1219
+
1220
+ private async loadExternalBugsByTestCase(
1221
+ externalBugsFile: MewpExternalFileRef | null | undefined
1222
+ ): Promise<Map<number, MewpBugLink[]>> {
1223
+ return this.mewpExternalIngestionUtils.loadExternalBugsByTestCase(externalBugsFile, {
1224
+ toComparableText: (value) => this.toMewpComparableText(value),
1225
+ toRequirementKey: (value) => this.toRequirementKey(value),
1226
+ resolveBugResponsibility: (fields) => this.resolveBugResponsibility(fields),
1227
+ isExternalStateInScope: (value, itemType) => this.isExternalStateInScope(value, itemType),
1228
+ isExcludedL3L4BySapWbs: (value) => this.isExcludedL3L4BySapWbs(value),
1229
+ resolveRequirementSapWbsByBaseKey: () => '',
1230
+ });
1231
+ }
1232
+
1233
+ private async loadExternalL3L4ByBaseKey(
1234
+ externalL3L4File: MewpExternalFileRef | null | undefined,
1235
+ requirementSapWbsByBaseKey: Map<string, string> = new Map<string, string>()
1236
+ ): Promise<Map<string, MewpL3L4Link[]>> {
1237
+ return this.mewpExternalIngestionUtils.loadExternalL3L4ByBaseKey(externalL3L4File, {
1238
+ toComparableText: (value) => this.toMewpComparableText(value),
1239
+ toRequirementKey: (value) => this.toRequirementKey(value),
1240
+ resolveBugResponsibility: (fields) => this.resolveBugResponsibility(fields),
1241
+ isExternalStateInScope: (value, itemType) => this.isExternalStateInScope(value, itemType),
1242
+ isExcludedL3L4BySapWbs: (value) => this.isExcludedL3L4BySapWbs(value),
1243
+ resolveRequirementSapWbsByBaseKey: (baseKey) => String(requirementSapWbsByBaseKey.get(baseKey) || ''),
1244
+ });
1245
+ }
1246
+
1247
+ private buildRequirementSapWbsByBaseKey(
1248
+ requirements: Array<Pick<MewpL2RequirementWorkItem, 'baseKey' | 'responsibility'>>
1249
+ ): Map<string, string> {
1250
+ const out = new Map<string, string>();
1251
+ for (const requirement of requirements || []) {
1252
+ const baseKey = String(requirement?.baseKey || '').trim();
1253
+ if (!baseKey) continue;
1254
+
1255
+ const normalized = this.resolveMewpResponsibility(this.toMewpComparableText(requirement?.responsibility));
1256
+ if (!normalized) continue;
1257
+
1258
+ const existing = out.get(baseKey) || '';
1259
+ // Keep ESUK as dominant if conflicting values are ever present across family items.
1260
+ if (existing === 'ESUK') continue;
1261
+ if (normalized === 'ESUK' || !existing) {
1262
+ out.set(baseKey, normalized);
1263
+ }
1264
+ }
1265
+ return out;
1266
+ }
1267
+
1268
+ private isExternalStateInScope(value: string, itemType: 'bug' | 'requirement'): boolean {
1269
+ const normalized = String(value || '').trim().toLowerCase();
1270
+ if (!normalized) return true;
1271
+
1272
+ // TFS/ADO processes usually don't expose a literal "Open" state.
1273
+ // Keep non-terminal states, exclude terminal states.
1274
+ const terminalStates = new Set<string>([
1275
+ 'resolved',
1276
+ 'closed',
1277
+ 'done',
1278
+ 'completed',
1279
+ 'complete',
1280
+ 'removed',
1281
+ 'rejected',
1282
+ 'cancelled',
1283
+ 'canceled',
1284
+ 'obsolete',
1285
+ ]);
1286
+
1287
+ if (terminalStates.has(normalized)) return false;
1288
+
1289
+ // Bug-specific terminal variants often used in custom processes.
1290
+ if (itemType === 'bug') {
1291
+ if (normalized === 'fixed') return false;
1292
+ }
1293
+
1294
+ return true;
1295
+ }
1296
+
1297
+ private invertBaseRequirementLinks(
1298
+ linkedRequirementsByTestCase: MewpLinkedRequirementsByTestCase
1299
+ ): Map<string, Set<number>> {
1300
+ const out = new Map<string, Set<number>>();
1301
+ for (const [testCaseId, links] of linkedRequirementsByTestCase.entries()) {
1302
+ for (const baseKey of links?.baseKeys || []) {
1303
+ if (!out.has(baseKey)) out.set(baseKey, new Set<number>());
1304
+ out.get(baseKey)!.add(testCaseId);
1305
+ }
1306
+ }
1307
+ return out;
1308
+ }
1309
+
634
1310
  private buildMewpTestCaseTitleMap(testData: any[]): Map<number, string> {
635
1311
  const map = new Map<number, string>();
636
1312
 
@@ -728,56 +1404,197 @@ export default class ResultDataProvider {
728
1404
  return 'notRun';
729
1405
  }
730
1406
 
731
- private accumulateRequirementCountsFromStepText(
732
- stepText: string,
733
- status: 'passed' | 'failed' | 'notRun',
1407
+ private accumulateRequirementCountsFromActionResults(
1408
+ actionResults: any[],
734
1409
  testCaseId: number,
735
1410
  requirementKeys: Set<string>,
736
1411
  counters: Map<string, Map<number, { passed: number; failed: number; notRun: number }>>,
737
1412
  observedTestCaseIdsByRequirement: Map<string, Set<number>>
738
1413
  ) {
739
1414
  if (!Number.isFinite(testCaseId) || testCaseId <= 0) return;
1415
+ const sortedResults = Array.isArray(actionResults) ? actionResults : [];
1416
+ let previousRequirementStepIndex = -1;
1417
+
1418
+ for (let i = 0; i < sortedResults.length; i++) {
1419
+ const actionResult = sortedResults[i];
1420
+ if (actionResult?.isSharedStepTitle) continue;
1421
+ const requirementCodes = this.extractRequirementCodesFromText(actionResult?.expected || '');
1422
+ if (requirementCodes.size === 0) continue;
1423
+
1424
+ const startIndex = previousRequirementStepIndex + 1;
1425
+ const status = this.resolveRequirementStatusForWindow(sortedResults, startIndex, i);
1426
+ previousRequirementStepIndex = i;
1427
+
1428
+ for (const code of requirementCodes) {
1429
+ if (requirementKeys.size > 0 && !requirementKeys.has(code)) continue;
1430
+ if (!counters.has(code)) {
1431
+ counters.set(code, new Map<number, { passed: number; failed: number; notRun: number }>());
1432
+ }
1433
+ const perTestCaseCounters = counters.get(code)!;
1434
+ if (!perTestCaseCounters.has(testCaseId)) {
1435
+ perTestCaseCounters.set(testCaseId, { passed: 0, failed: 0, notRun: 0 });
1436
+ }
740
1437
 
741
- const codes = this.extractRequirementCodesFromText(stepText);
742
- for (const code of codes) {
743
- if (requirementKeys.size > 0 && !requirementKeys.has(code)) continue;
744
- if (!counters.has(code)) {
745
- counters.set(code, new Map<number, { passed: number; failed: number; notRun: number }>());
746
- }
747
- const perTestCaseCounters = counters.get(code)!;
748
- if (!perTestCaseCounters.has(testCaseId)) {
749
- perTestCaseCounters.set(testCaseId, { passed: 0, failed: 0, notRun: 0 });
750
- }
1438
+ if (!observedTestCaseIdsByRequirement.has(code)) {
1439
+ observedTestCaseIdsByRequirement.set(code, new Set<number>());
1440
+ }
1441
+ observedTestCaseIdsByRequirement.get(code)!.add(testCaseId);
751
1442
 
752
- if (!observedTestCaseIdsByRequirement.has(code)) {
753
- observedTestCaseIdsByRequirement.set(code, new Set<number>());
1443
+ const counter = perTestCaseCounters.get(testCaseId)!;
1444
+ if (status === 'passed') counter.passed += 1;
1445
+ else if (status === 'failed') counter.failed += 1;
1446
+ else counter.notRun += 1;
754
1447
  }
755
- observedTestCaseIdsByRequirement.get(code)!.add(testCaseId);
1448
+ }
1449
+ }
756
1450
 
757
- const counter = perTestCaseCounters.get(testCaseId)!;
758
- if (status === 'passed') counter.passed += 1;
759
- else if (status === 'failed') counter.failed += 1;
760
- else counter.notRun += 1;
1451
+ private resolveRequirementStatusForWindow(
1452
+ actionResults: any[],
1453
+ startIndex: number,
1454
+ endIndex: number
1455
+ ): 'passed' | 'failed' | 'notRun' {
1456
+ let hasNotRun = false;
1457
+ for (let index = startIndex; index <= endIndex; index++) {
1458
+ const status = this.classifyRequirementStepOutcome(actionResults[index]?.outcome);
1459
+ if (status === 'failed') return 'failed';
1460
+ if (status === 'notRun') hasNotRun = true;
761
1461
  }
1462
+ return hasNotRun ? 'notRun' : 'passed';
762
1463
  }
763
1464
 
764
1465
  private extractRequirementCodesFromText(text: string): Set<string> {
1466
+ return this.extractRequirementCodesFromExpectedText(text, false);
1467
+ }
1468
+
1469
+ private extractRequirementMentionsFromExpectedSteps(
1470
+ steps: TestSteps[],
1471
+ includeSuffix: boolean
1472
+ ): Array<{ stepRef: string; codes: Set<string> }> {
1473
+ const out: Array<{ stepRef: string; codes: Set<string> }> = [];
1474
+ const allSteps = Array.isArray(steps) ? steps : [];
1475
+ for (let index = 0; index < allSteps.length; index += 1) {
1476
+ const step = allSteps[index];
1477
+ if (step?.isSharedStepTitle) continue;
1478
+ const codes = this.extractRequirementCodesFromExpectedText(step?.expected || '', includeSuffix);
1479
+ if (codes.size === 0) continue;
1480
+ out.push({
1481
+ stepRef: this.resolveValidationStepReference(step, index),
1482
+ codes,
1483
+ });
1484
+ }
1485
+ return out;
1486
+ }
1487
+
1488
+ private extractRequirementCodesFromExpectedSteps(steps: TestSteps[], includeSuffix: boolean): Set<string> {
1489
+ const out = new Set<string>();
1490
+ for (const step of Array.isArray(steps) ? steps : []) {
1491
+ if (step?.isSharedStepTitle) continue;
1492
+ const codes = this.extractRequirementCodesFromExpectedText(step?.expected || '', includeSuffix);
1493
+ codes.forEach((code) => out.add(code));
1494
+ }
1495
+ return out;
1496
+ }
1497
+
1498
+ private extractRequirementCodesFromExpectedText(text: string, includeSuffix: boolean): Set<string> {
765
1499
  const out = new Set<string>();
766
1500
  const source = this.normalizeRequirementStepText(text);
767
- // Supports SR<ID> patterns even when HTML formatting breaks the token,
768
- // e.g. "S<b>R</b> 0 0 1" or "S R 0 0 1".
769
- const regex = /S[\s\u00A0]*R(?:[\s\u00A0\-_]*\d){1,12}/gi;
770
- let match: RegExpExecArray | null = null;
771
- while ((match = regex.exec(source)) !== null) {
772
- const digitsOnly = String(match[0] || '').replace(/\D/g, '');
773
- const digits = Number.parseInt(digitsOnly, 10);
774
- if (Number.isFinite(digits)) {
775
- out.add(`SR${digits}`);
1501
+ if (!source) return out;
1502
+
1503
+ const tokens = source
1504
+ .split(';')
1505
+ .map((token) => String(token || '').trim())
1506
+ .filter((token) => token !== '');
1507
+
1508
+ for (const token of tokens) {
1509
+ const candidates = this.extractRequirementCandidatesFromToken(token);
1510
+ for (const candidate of candidates) {
1511
+ const expandedTokens = this.expandRequirementTokenByComma(candidate);
1512
+ for (const expandedToken of expandedTokens) {
1513
+ if (!expandedToken || /vvrm/i.test(expandedToken)) continue;
1514
+ const normalized = this.normalizeRequirementCodeToken(expandedToken, includeSuffix);
1515
+ if (normalized) {
1516
+ out.add(normalized);
1517
+ }
1518
+ }
776
1519
  }
777
1520
  }
1521
+
778
1522
  return out;
779
1523
  }
780
1524
 
1525
+ private extractRequirementCandidatesFromToken(token: string): string[] {
1526
+ const source = String(token || '');
1527
+ if (!source) return [];
1528
+ const out = new Set<string>();
1529
+ const collectCandidates = (input: string, rejectTailPattern: RegExp) => {
1530
+ for (const match of input.matchAll(/SR\d{4,}(?:-\d+(?:,\d+)*)?/gi)) {
1531
+ const matchedValue = String(match?.[0] || '')
1532
+ .trim()
1533
+ .toUpperCase();
1534
+ if (!matchedValue) continue;
1535
+ const endIndex = Number(match?.index || 0) + matchedValue.length;
1536
+ const tail = String(input.slice(endIndex) || '');
1537
+ if (rejectTailPattern.test(tail)) continue;
1538
+ out.add(matchedValue);
1539
+ }
1540
+ };
1541
+
1542
+ // Normal scan keeps punctuation context (" SR0817-V3.2 " -> reject via tail).
1543
+ collectCandidates(source, /^\s*(?:V\d|VVRM|-V\d)/i);
1544
+
1545
+ // Compact scan preserves legacy support for spaced SR letters/digits
1546
+ // such as "S R 0 0 0 1" and HTML-fragmented tokens.
1547
+ const compactSource = source.replace(/\s+/g, '');
1548
+ if (compactSource && compactSource !== source) {
1549
+ collectCandidates(compactSource, /^(?:V\d|VVRM|-V\d)/i);
1550
+ }
1551
+
1552
+ return [...out];
1553
+ }
1554
+
1555
+ private expandRequirementTokenByComma(token: string): string[] {
1556
+ const compact = String(token || '').trim().toUpperCase();
1557
+ if (!compact) return [];
1558
+
1559
+ const suffixBatchMatch = /^SR(\d{4,})-(\d+(?:,\d+)+)$/.exec(compact);
1560
+ if (suffixBatchMatch) {
1561
+ const base = String(suffixBatchMatch[1] || '').trim();
1562
+ const suffixes = String(suffixBatchMatch[2] || '')
1563
+ .split(',')
1564
+ .map((item) => String(item || '').trim())
1565
+ .filter((item) => /^\d+$/.test(item));
1566
+ return suffixes.map((suffix) => `SR${base}-${suffix}`);
1567
+ }
1568
+
1569
+ return compact
1570
+ .split(',')
1571
+ .map((part) => String(part || '').trim())
1572
+ .filter((part) => !!part);
1573
+ }
1574
+
1575
+ private normalizeRequirementCodeToken(token: string, includeSuffix: boolean): string {
1576
+ const compact = String(token || '')
1577
+ .trim()
1578
+ .replace(/[\u200B-\u200D\uFEFF]/g, '')
1579
+ .toUpperCase();
1580
+ if (!compact) return '';
1581
+
1582
+ const pattern = includeSuffix ? /^SR(\d{4,})(?:-(\d+))?$/ : /^SR(\d{4,})(?:-\d+)?$/;
1583
+ const match = pattern.exec(compact);
1584
+ if (!match) return '';
1585
+
1586
+ const baseDigits = String(match[1] || '').trim();
1587
+ if (!baseDigits) return '';
1588
+
1589
+ if (includeSuffix && match[2]) {
1590
+ const suffixDigits = String(match[2] || '').trim();
1591
+ if (!suffixDigits) return '';
1592
+ return `SR${baseDigits}-${suffixDigits}`;
1593
+ }
1594
+
1595
+ return `SR${baseDigits}`;
1596
+ }
1597
+
781
1598
  private normalizeRequirementStepText(text: string): string {
782
1599
  const raw = String(text || '');
783
1600
  if (!raw) return '';
@@ -794,28 +1611,19 @@ export default class ResultDataProvider {
794
1611
  .replace(/\s+/g, ' ');
795
1612
  }
796
1613
 
1614
+ private resolveValidationStepReference(step: TestSteps, index: number): string {
1615
+ const fromPosition = String(step?.stepPosition || '').trim();
1616
+ if (fromPosition) return `Step ${fromPosition}`;
1617
+ const fromId = String(step?.stepId || '').trim();
1618
+ if (fromId) return `Step ${fromId}`;
1619
+ return `Step ${index + 1}`;
1620
+ }
1621
+
797
1622
  private toRequirementKey(requirementId: string): string {
798
- const normalized = this.normalizeMewpRequirementCode(requirementId);
799
- if (!normalized) return '';
800
- const digits = Number.parseInt(normalized.replace(/^SR/i, ''), 10);
801
- if (!Number.isFinite(digits)) return '';
802
- return `SR${digits}`;
803
- }
804
-
805
- private async fetchMewpL2Requirements(projectName: string, linkedQueryRequest?: any): Promise<
806
- Array<{
807
- workItemId: number;
808
- requirementId: string;
809
- title: string;
810
- responsibility: string;
811
- linkedTestCaseIds: number[];
812
- }>
813
- > {
814
- const queryHref = this.extractMewpQueryHref(linkedQueryRequest);
815
- if (queryHref) {
816
- return this.fetchMewpL2RequirementsFromQuery(projectName, queryHref);
817
- }
1623
+ return this.normalizeMewpRequirementCode(requirementId);
1624
+ }
818
1625
 
1626
+ private async fetchMewpL2Requirements(projectName: string): Promise<MewpL2RequirementWorkItem[]> {
819
1627
  const workItemTypeNames = await this.fetchMewpRequirementTypeNames(projectName);
820
1628
  if (workItemTypeNames.length === 0) {
821
1629
  return [];
@@ -824,17 +1632,36 @@ export default class ResultDataProvider {
824
1632
  const quotedTypeNames = workItemTypeNames
825
1633
  .map((name) => `'${String(name).replace(/'/g, "''")}'`)
826
1634
  .join(', ');
827
- const wiql = `SELECT [System.Id]
828
- FROM WorkItems
829
- WHERE [System.TeamProject] = @project
830
- AND [System.WorkItemType] IN (${quotedTypeNames})
831
- ORDER BY [System.Id]`;
832
- const wiqlUrl = `${this.orgUrl}${projectName}/_apis/wit/wiql?api-version=7.1-preview.2`;
833
- const wiqlResponse = await TFSServices.postRequest(wiqlUrl, this.token, 'Post', { query: wiql }, null);
834
- const workItemRefs = Array.isArray(wiqlResponse?.data?.workItems) ? wiqlResponse.data.workItems : [];
835
- const requirementIds = workItemRefs
836
- .map((item: any) => Number(item?.id))
837
- .filter((id: number) => Number.isFinite(id));
1635
+ const queryRequirementIds = async (l2AreaPath: string | null): Promise<number[]> => {
1636
+ const escapedAreaPath = l2AreaPath ? String(l2AreaPath).replace(/'/g, "''") : '';
1637
+ const areaFilter = escapedAreaPath ? `\n AND [System.AreaPath] UNDER '${escapedAreaPath}'` : '';
1638
+ const wiql = `SELECT [System.Id]
1639
+ FROM WorkItems
1640
+ WHERE [System.TeamProject] = @project
1641
+ AND [System.WorkItemType] IN (${quotedTypeNames})${areaFilter}
1642
+ ORDER BY [System.Id]`;
1643
+ const wiqlUrl = `${this.orgUrl}${projectName}/_apis/wit/wiql?api-version=7.1-preview.2`;
1644
+ const wiqlResponse = await TFSServices.postRequest(wiqlUrl, this.token, 'Post', { query: wiql }, null);
1645
+ const workItemRefs = Array.isArray(wiqlResponse?.data?.workItems) ? wiqlResponse.data.workItems : [];
1646
+ return workItemRefs
1647
+ .map((item: any) => Number(item?.id))
1648
+ .filter((id: number) => Number.isFinite(id));
1649
+ };
1650
+
1651
+ const defaultL2AreaPath = `${String(projectName || '').trim()}\\Customer Requirements\\Level 2`;
1652
+ let requirementIds: number[] = [];
1653
+ try {
1654
+ requirementIds = await queryRequirementIds(defaultL2AreaPath);
1655
+ } catch (error: any) {
1656
+ logger.warn(
1657
+ `Could not apply MEWP L2 WIQL area-path optimization. Falling back to full requirement scope: ${
1658
+ error?.message || error
1659
+ }`
1660
+ );
1661
+ }
1662
+ if (requirementIds.length === 0) {
1663
+ requirementIds = await queryRequirementIds(null);
1664
+ }
838
1665
 
839
1666
  if (requirementIds.length === 0) {
840
1667
  return [];
@@ -843,235 +1670,294 @@ ORDER BY [System.Id]`;
843
1670
  const workItems = await this.fetchWorkItemsByIds(projectName, requirementIds, true);
844
1671
  const requirements = workItems.map((wi: any) => {
845
1672
  const fields = wi?.fields || {};
1673
+ const requirementId = this.extractMewpRequirementIdentifier(fields, Number(wi?.id || 0));
1674
+ const areaPath = this.toMewpComparableText(fields?.['System.AreaPath']);
846
1675
  return {
847
1676
  workItemId: Number(wi?.id || 0),
848
- requirementId: this.extractMewpRequirementIdentifier(fields, Number(wi?.id || 0)),
849
- title: String(fields['System.Title'] || ''),
1677
+ requirementId,
1678
+ baseKey: this.toRequirementKey(requirementId),
1679
+ title: this.toMewpComparableText(fields?.['System.Title'] || wi?.title),
1680
+ subSystem: this.deriveMewpSubSystem(fields),
850
1681
  responsibility: this.deriveMewpResponsibility(fields),
851
1682
  linkedTestCaseIds: this.extractLinkedTestCaseIdsFromRequirement(wi?.relations || []),
1683
+ relatedWorkItemIds: this.extractLinkedWorkItemIdsFromRelations(wi?.relations || []),
1684
+ areaPath,
852
1685
  };
853
1686
  });
854
1687
 
855
- return requirements.sort((a, b) => String(a.requirementId).localeCompare(String(b.requirementId)));
1688
+ return requirements
1689
+ .filter((item) => {
1690
+ if (!item.baseKey) return false;
1691
+ if (!item.areaPath) return true;
1692
+ return this.isMewpL2AreaPath(item.areaPath);
1693
+ })
1694
+ .sort((a, b) => String(a.requirementId).localeCompare(String(b.requirementId)));
856
1695
  }
857
1696
 
858
- private extractMewpQueryHref(linkedQueryRequest?: any): string {
859
- const mode = String(linkedQueryRequest?.linkedQueryMode || '')
1697
+ private isMewpL2AreaPath(areaPath: string): boolean {
1698
+ const normalized = String(areaPath || '')
860
1699
  .trim()
861
- .toLowerCase();
862
- if (mode !== 'query') return '';
863
-
864
- return String(linkedQueryRequest?.testAssociatedQuery?.wiql?.href || '').trim();
1700
+ .toLowerCase()
1701
+ .replace(/\//g, '\\');
1702
+ if (!normalized) return false;
1703
+ return normalized.includes('\\customer requirements\\level 2');
865
1704
  }
866
1705
 
867
- private async fetchMewpL2RequirementsFromQuery(
868
- projectName: string,
869
- queryHref: string
870
- ): Promise<
871
- Array<{
872
- workItemId: number;
873
- requirementId: string;
874
- title: string;
875
- responsibility: string;
876
- linkedTestCaseIds: number[];
877
- }>
878
- > {
879
- try {
880
- const ticketsDataProvider = new TicketsDataProvider(this.orgUrl, this.token);
881
- const queryResult = await ticketsDataProvider.GetQueryResultsFromWiql(
882
- queryHref,
883
- true,
884
- new Map<number, Set<any>>()
885
- );
1706
+ private collapseMewpRequirementFamilies(
1707
+ requirements: MewpL2RequirementWorkItem[],
1708
+ scopedRequirementKeys?: Set<string>
1709
+ ): MewpL2RequirementFamily[] {
1710
+ const families = new Map<
1711
+ string,
1712
+ {
1713
+ representative: MewpL2RequirementWorkItem;
1714
+ score: number;
1715
+ linkedTestCaseIds: Set<number>;
1716
+ }
1717
+ >();
886
1718
 
887
- const requirementTypeNames = await this.fetchMewpRequirementTypeNames(projectName);
888
- const requirementTypeSet = new Set(
889
- requirementTypeNames.map((name) => String(name || '').trim().toLowerCase())
890
- );
1719
+ const calcScore = (item: MewpL2RequirementWorkItem) => {
1720
+ const requirementId = String(item?.requirementId || '').trim();
1721
+ const areaPath = String(item?.areaPath || '')
1722
+ .trim()
1723
+ .toLowerCase();
1724
+ let score = 0;
1725
+ if (/^SR\d+$/i.test(requirementId)) score += 6;
1726
+ if (areaPath.includes('\\customer requirements\\level 2')) score += 3;
1727
+ if (!areaPath.includes('\\mop')) score += 2;
1728
+ if (String(item?.title || '').trim()) score += 1;
1729
+ if (String(item?.subSystem || '').trim()) score += 1;
1730
+ if (String(item?.responsibility || '').trim()) score += 1;
1731
+ return score;
1732
+ };
891
1733
 
892
- const requirementsById = new Map<
893
- number,
894
- {
895
- workItemId: number;
896
- requirementId: string;
897
- title: string;
898
- responsibility: string;
899
- linkedTestCaseIds: Set<number>;
900
- }
901
- >();
1734
+ for (const requirement of requirements || []) {
1735
+ const baseKey = String(requirement?.baseKey || '').trim();
1736
+ if (!baseKey) continue;
1737
+ if (scopedRequirementKeys?.size && !scopedRequirementKeys.has(baseKey)) continue;
902
1738
 
903
- const upsertRequirement = (workItem: any) => {
904
- this.upsertMewpRequirement(requirementsById, workItem, requirementTypeSet);
905
- };
1739
+ if (!families.has(baseKey)) {
1740
+ families.set(baseKey, {
1741
+ representative: requirement,
1742
+ score: calcScore(requirement),
1743
+ linkedTestCaseIds: new Set<number>(),
1744
+ });
1745
+ }
1746
+ const family = families.get(baseKey)!;
1747
+ const score = calcScore(requirement);
1748
+ if (score > family.score) {
1749
+ family.representative = requirement;
1750
+ family.score = score;
1751
+ }
1752
+ for (const testCaseId of requirement?.linkedTestCaseIds || []) {
1753
+ if (Number.isFinite(testCaseId) && Number(testCaseId) > 0) {
1754
+ family.linkedTestCaseIds.add(Number(testCaseId));
1755
+ }
1756
+ }
1757
+ }
906
1758
 
907
- const linkRequirementToTestCase = (requirementWorkItem: any, testCaseWorkItem: any) => {
908
- const requirementId = Number(requirementWorkItem?.id || 0);
909
- const testCaseId = Number(testCaseWorkItem?.id || 0);
910
- if (!Number.isFinite(requirementId) || requirementId <= 0) return;
911
- if (!Number.isFinite(testCaseId) || testCaseId <= 0) return;
1759
+ return [...families.entries()]
1760
+ .map(([baseKey, family]) => ({
1761
+ requirementId: String(family?.representative?.requirementId || baseKey),
1762
+ baseKey,
1763
+ title: String(family?.representative?.title || ''),
1764
+ subSystem: String(family?.representative?.subSystem || ''),
1765
+ responsibility: String(family?.representative?.responsibility || ''),
1766
+ linkedTestCaseIds: [...family.linkedTestCaseIds].sort((a, b) => a - b),
1767
+ }))
1768
+ .sort((a, b) => String(a.requirementId).localeCompare(String(b.requirementId)));
1769
+ }
912
1770
 
913
- upsertRequirement(requirementWorkItem);
914
- const requirement = requirementsById.get(requirementId);
915
- if (!requirement) return;
916
- requirement.linkedTestCaseIds.add(testCaseId);
917
- };
1771
+ private buildRequirementFamilyMap(
1772
+ requirements: Array<Pick<MewpL2RequirementWorkItem, 'requirementId' | 'baseKey'>>,
1773
+ scopedRequirementKeys?: Set<string>
1774
+ ): Map<string, Set<string>> {
1775
+ const familyMap = new Map<string, Set<string>>();
1776
+ for (const requirement of requirements || []) {
1777
+ const baseKey = String(requirement?.baseKey || '').trim();
1778
+ if (!baseKey) continue;
1779
+ if (scopedRequirementKeys?.size && !scopedRequirementKeys.has(baseKey)) continue;
1780
+ const fullCode = this.normalizeMewpRequirementCodeWithSuffix(requirement?.requirementId || '');
1781
+ if (!fullCode) continue;
1782
+ if (!familyMap.has(baseKey)) familyMap.set(baseKey, new Set<string>());
1783
+ familyMap.get(baseKey)!.add(fullCode);
1784
+ }
1785
+ return familyMap;
1786
+ }
918
1787
 
919
- if (Array.isArray(queryResult?.fetchedWorkItems)) {
920
- for (const workItem of queryResult.fetchedWorkItems) {
921
- upsertRequirement(workItem);
922
- }
1788
+ private async buildLinkedRequirementsByTestCase(
1789
+ requirements: Array<
1790
+ Pick<MewpL2RequirementWorkItem, 'workItemId' | 'requirementId' | 'baseKey' | 'linkedTestCaseIds'>
1791
+ >,
1792
+ testData: any[],
1793
+ projectName: string
1794
+ ): Promise<MewpLinkedRequirementsByTestCase> {
1795
+ const map: MewpLinkedRequirementsByTestCase = new Map();
1796
+ const ensure = (testCaseId: number) => {
1797
+ if (!map.has(testCaseId)) {
1798
+ map.set(testCaseId, {
1799
+ baseKeys: new Set<string>(),
1800
+ fullCodes: new Set<string>(),
1801
+ bugIds: new Set<number>(),
1802
+ });
923
1803
  }
1804
+ return map.get(testCaseId)!;
1805
+ };
924
1806
 
925
- if (queryResult?.sourceTargetsMap && typeof queryResult.sourceTargetsMap.entries === 'function') {
926
- for (const [sourceItem, targets] of queryResult.sourceTargetsMap.entries()) {
927
- const sourceType = this.getMewpWorkItemType(sourceItem);
928
- const sourceIsRequirement = this.isMewpRequirementType(sourceType, requirementTypeSet);
929
- const sourceIsTestCase = this.isMewpTestCaseType(sourceType);
930
-
931
- if (sourceIsRequirement) {
932
- upsertRequirement(sourceItem);
933
- }
1807
+ const requirementById = new Map<number, { baseKey: string; fullCode: string }>();
1808
+ for (const requirement of requirements || []) {
1809
+ const workItemId = Number(requirement?.workItemId || 0);
1810
+ const baseKey = String(requirement?.baseKey || '').trim();
1811
+ const fullCode = this.normalizeMewpRequirementCodeWithSuffix(requirement?.requirementId || '');
1812
+ if (workItemId > 0 && baseKey && fullCode) {
1813
+ requirementById.set(workItemId, { baseKey, fullCode });
1814
+ }
934
1815
 
935
- const relatedItems = Array.isArray(targets) ? targets : [];
936
- for (const targetItem of relatedItems) {
937
- const targetType = this.getMewpWorkItemType(targetItem);
938
- const targetIsRequirement = this.isMewpRequirementType(targetType, requirementTypeSet);
939
- const targetIsTestCase = this.isMewpTestCaseType(targetType);
1816
+ for (const testCaseIdRaw of requirement?.linkedTestCaseIds || []) {
1817
+ const testCaseId = Number(testCaseIdRaw);
1818
+ if (!Number.isFinite(testCaseId) || testCaseId <= 0 || !baseKey || !fullCode) continue;
1819
+ const entry = ensure(testCaseId);
1820
+ entry.baseKeys.add(baseKey);
1821
+ entry.fullCodes.add(fullCode);
1822
+ }
1823
+ }
940
1824
 
941
- if (targetIsRequirement) {
942
- upsertRequirement(targetItem);
943
- }
1825
+ const testCaseIds = new Set<number>();
1826
+ for (const suite of testData || []) {
1827
+ const testCasesItems = Array.isArray(suite?.testCasesItems) ? suite.testCasesItems : [];
1828
+ for (const testCase of testCasesItems) {
1829
+ const id = Number(testCase?.workItem?.id || testCase?.testCaseId || testCase?.id || 0);
1830
+ if (Number.isFinite(id) && id > 0) testCaseIds.add(id);
1831
+ }
1832
+ const testPointsItems = Array.isArray(suite?.testPointsItems) ? suite.testPointsItems : [];
1833
+ for (const testPoint of testPointsItems) {
1834
+ const id = Number(testPoint?.testCaseId || testPoint?.testCase?.id || 0);
1835
+ if (Number.isFinite(id) && id > 0) testCaseIds.add(id);
1836
+ }
1837
+ }
944
1838
 
945
- if (sourceIsRequirement && targetIsTestCase) {
946
- linkRequirementToTestCase(sourceItem, targetItem);
947
- } else if (sourceIsTestCase && targetIsRequirement) {
948
- linkRequirementToTestCase(targetItem, sourceItem);
949
- }
1839
+ const relatedIdsByTestCase = new Map<number, Set<number>>();
1840
+ const allRelatedIds = new Set<number>();
1841
+ if (testCaseIds.size > 0) {
1842
+ const testCaseWorkItems = await this.fetchWorkItemsByIds(projectName, [...testCaseIds], true);
1843
+ for (const workItem of testCaseWorkItems || []) {
1844
+ const testCaseId = Number(workItem?.id || 0);
1845
+ if (!Number.isFinite(testCaseId) || testCaseId <= 0) continue;
1846
+ const relations = Array.isArray(workItem?.relations) ? workItem.relations : [];
1847
+ if (!relatedIdsByTestCase.has(testCaseId)) relatedIdsByTestCase.set(testCaseId, new Set<number>());
1848
+ for (const relation of relations) {
1849
+ const linkedWorkItemId = this.extractLinkedWorkItemIdFromRelation(relation);
1850
+ if (!linkedWorkItemId) continue;
1851
+ relatedIdsByTestCase.get(testCaseId)!.add(linkedWorkItemId);
1852
+ allRelatedIds.add(linkedWorkItemId);
1853
+
1854
+ if (this.isTestCaseToRequirementRelation(relation) && requirementById.has(linkedWorkItemId)) {
1855
+ const linkedRequirement = requirementById.get(linkedWorkItemId)!;
1856
+ const entry = ensure(testCaseId);
1857
+ entry.baseKeys.add(linkedRequirement.baseKey);
1858
+ entry.fullCodes.add(linkedRequirement.fullCode);
950
1859
  }
951
1860
  }
952
1861
  }
953
-
954
- await this.hydrateMewpRequirementsFromWorkItems(projectName, requirementsById);
955
-
956
- return [...requirementsById.values()]
957
- .map((requirement) => ({
958
- workItemId: requirement.workItemId,
959
- requirementId: requirement.requirementId,
960
- title: requirement.title,
961
- responsibility: requirement.responsibility,
962
- linkedTestCaseIds: [...requirement.linkedTestCaseIds].sort((a, b) => a - b),
963
- }))
964
- .sort((a, b) => String(a.requirementId).localeCompare(String(b.requirementId)));
965
- } catch (error: any) {
966
- logger.error(`Could not fetch MEWP requirements from query: ${error?.message || error}`);
967
- return [];
968
1862
  }
969
- }
970
1863
 
971
- private upsertMewpRequirement(
972
- requirementsById: Map<
973
- number,
974
- {
975
- workItemId: number;
976
- requirementId: string;
977
- title: string;
978
- responsibility: string;
979
- linkedTestCaseIds: Set<number>;
1864
+ if (allRelatedIds.size > 0) {
1865
+ const relatedWorkItems = await this.fetchWorkItemsByIds(projectName, [...allRelatedIds], false);
1866
+ const typeById = new Map<number, string>();
1867
+ for (const workItem of relatedWorkItems || []) {
1868
+ const id = Number(workItem?.id || 0);
1869
+ if (!Number.isFinite(id) || id <= 0) continue;
1870
+ const type = String(workItem?.fields?.['System.WorkItemType'] || '')
1871
+ .trim()
1872
+ .toLowerCase();
1873
+ typeById.set(id, type);
980
1874
  }
981
- >,
982
- workItem: any,
983
- requirementTypeSet: Set<string>
984
- ) {
985
- const workItemId = Number(workItem?.id || 0);
986
- if (!Number.isFinite(workItemId) || workItemId <= 0) return;
987
-
988
- const fields = workItem?.fields || {};
989
- const workItemType = this.getMewpWorkItemType(workItem);
990
- if (!this.isMewpRequirementType(workItemType, requirementTypeSet)) return;
991
-
992
- const existing = requirementsById.get(workItemId) || {
993
- workItemId,
994
- requirementId: String(workItemId),
995
- title: '',
996
- responsibility: '',
997
- linkedTestCaseIds: new Set<number>(),
998
- };
999
-
1000
- const extractedRequirementId = this.extractMewpRequirementIdentifier(fields, workItemId);
1001
- const extractedTitle = this.toMewpComparableText(fields?.['System.Title']);
1002
- const extractedResponsibility = this.deriveMewpResponsibility(fields);
1003
1875
 
1004
- existing.requirementId = extractedRequirementId || existing.requirementId || String(workItemId);
1005
- if (extractedTitle) {
1006
- existing.title = extractedTitle;
1007
- }
1008
- if (extractedResponsibility) {
1009
- existing.responsibility = extractedResponsibility;
1876
+ for (const [testCaseId, ids] of relatedIdsByTestCase.entries()) {
1877
+ const entry = ensure(testCaseId);
1878
+ for (const linkedId of ids) {
1879
+ const linkedType = typeById.get(linkedId) || '';
1880
+ if (linkedType === 'bug') {
1881
+ entry.bugIds.add(linkedId);
1882
+ }
1883
+ }
1884
+ }
1010
1885
  }
1011
1886
 
1012
- requirementsById.set(workItemId, existing);
1887
+ return map;
1013
1888
  }
1014
1889
 
1015
- private async hydrateMewpRequirementsFromWorkItems(
1016
- projectName: string,
1017
- requirementsById: Map<
1018
- number,
1019
- {
1020
- workItemId: number;
1021
- requirementId: string;
1022
- title: string;
1023
- responsibility: string;
1024
- linkedTestCaseIds: Set<number>;
1890
+ private async resolveMewpRequirementScopeKeysFromQuery(
1891
+ linkedQueryRequest: any,
1892
+ requirements: Array<Pick<MewpL2RequirementWorkItem, 'workItemId' | 'baseKey'>>,
1893
+ linkedRequirementsByTestCase: MewpLinkedRequirementsByTestCase
1894
+ ): Promise<Set<string> | undefined> {
1895
+ const mode = String(linkedQueryRequest?.linkedQueryMode || '')
1896
+ .trim()
1897
+ .toLowerCase();
1898
+ const wiqlHref = String(linkedQueryRequest?.testAssociatedQuery?.wiql?.href || '').trim();
1899
+ if (mode !== 'query' || !wiqlHref) return undefined;
1900
+
1901
+ try {
1902
+ const queryResult = await TFSServices.getItemContent(wiqlHref, this.token);
1903
+ const queryIds = new Set<number>();
1904
+ if (Array.isArray(queryResult?.workItems)) {
1905
+ for (const workItem of queryResult.workItems) {
1906
+ const id = Number(workItem?.id || 0);
1907
+ if (Number.isFinite(id) && id > 0) queryIds.add(id);
1908
+ }
1025
1909
  }
1026
- >
1027
- ) {
1028
- const requirementIds = [...requirementsById.keys()];
1029
- if (requirementIds.length === 0) return;
1030
-
1031
- const fetchedRequirements = await this.fetchWorkItemsByIds(projectName, requirementIds, true);
1032
- for (const requirementWorkItem of fetchedRequirements) {
1033
- const workItemId = Number(requirementWorkItem?.id || 0);
1034
- if (!Number.isFinite(workItemId) || workItemId <= 0) continue;
1035
- const current = requirementsById.get(workItemId);
1036
- if (!current) continue;
1037
-
1038
- const fields = requirementWorkItem?.fields || {};
1039
- const requirementId = this.extractMewpRequirementIdentifier(fields, workItemId);
1040
- const title = this.toMewpComparableText(fields?.['System.Title']);
1041
- const responsibility = this.deriveMewpResponsibility(fields);
1042
- const linkedTestCaseIds = this.extractLinkedTestCaseIdsFromRequirement(
1043
- requirementWorkItem?.relations || []
1044
- );
1910
+ if (Array.isArray(queryResult?.workItemRelations)) {
1911
+ for (const relation of queryResult.workItemRelations) {
1912
+ const sourceId = Number(relation?.source?.id || 0);
1913
+ const targetId = Number(relation?.target?.id || 0);
1914
+ if (Number.isFinite(sourceId) && sourceId > 0) queryIds.add(sourceId);
1915
+ if (Number.isFinite(targetId) && targetId > 0) queryIds.add(targetId);
1916
+ }
1917
+ }
1918
+
1919
+ if (queryIds.size === 0) return undefined;
1045
1920
 
1046
- current.requirementId = requirementId || current.requirementId || String(workItemId);
1047
- if (title) {
1048
- current.title = title;
1921
+ const reqIdToBaseKey = new Map<number, string>();
1922
+ for (const requirement of requirements || []) {
1923
+ const id = Number(requirement?.workItemId || 0);
1924
+ const baseKey = String(requirement?.baseKey || '').trim();
1925
+ if (id > 0 && baseKey) reqIdToBaseKey.set(id, baseKey);
1049
1926
  }
1050
- if (responsibility) {
1051
- current.responsibility = responsibility;
1927
+
1928
+ const scopedKeys = new Set<string>();
1929
+ for (const queryId of queryIds) {
1930
+ if (reqIdToBaseKey.has(queryId)) {
1931
+ scopedKeys.add(reqIdToBaseKey.get(queryId)!);
1932
+ continue;
1933
+ }
1934
+
1935
+ const linked = linkedRequirementsByTestCase.get(queryId);
1936
+ if (!linked?.baseKeys?.size) continue;
1937
+ linked.baseKeys.forEach((baseKey) => scopedKeys.add(baseKey));
1052
1938
  }
1053
- linkedTestCaseIds.forEach((testCaseId) => current.linkedTestCaseIds.add(testCaseId));
1054
- }
1055
- }
1056
1939
 
1057
- private getMewpWorkItemType(workItem: any): string {
1058
- return this.toMewpComparableText(workItem?.fields?.['System.WorkItemType']);
1940
+ return scopedKeys.size > 0 ? scopedKeys : undefined;
1941
+ } catch (error: any) {
1942
+ logger.warn(`Could not resolve MEWP query scope: ${error?.message || error}`);
1943
+ return undefined;
1944
+ }
1059
1945
  }
1060
1946
 
1061
- private isMewpRequirementType(workItemType: string, requirementTypeSet: Set<string>): boolean {
1062
- const normalized = String(workItemType || '')
1947
+ private isTestCaseToRequirementRelation(relation: any): boolean {
1948
+ const rel = String(relation?.rel || '')
1063
1949
  .trim()
1064
1950
  .toLowerCase();
1065
- if (!normalized) return false;
1066
- if (requirementTypeSet.has(normalized)) return true;
1067
- return normalized.includes('requirement') || normalized === 'epic';
1951
+ if (!rel) return false;
1952
+ return rel.includes('testedby-reverse') || (rel.includes('tests') && rel.includes('reverse'));
1068
1953
  }
1069
1954
 
1070
- private isMewpTestCaseType(workItemType: string): boolean {
1071
- const normalized = String(workItemType || '')
1072
- .trim()
1073
- .toLowerCase();
1074
- return normalized === 'test case' || normalized === 'testcase';
1955
+ private extractLinkedWorkItemIdFromRelation(relation: any): number {
1956
+ const url = String(relation?.url || '');
1957
+ const match = /\/workItems\/(\d+)/i.exec(url);
1958
+ if (!match) return 0;
1959
+ const parsed = Number(match[1]);
1960
+ return Number.isFinite(parsed) ? parsed : 0;
1075
1961
  }
1076
1962
 
1077
1963
  private async fetchMewpRequirementTypeNames(projectName: string): Promise<string[]> {
@@ -1135,6 +2021,18 @@ ORDER BY [System.Id]`;
1135
2021
  return [...out].sort((a, b) => a - b);
1136
2022
  }
1137
2023
 
2024
+ private extractLinkedWorkItemIdsFromRelations(relations: any[]): number[] {
2025
+ const out = new Set<number>();
2026
+ for (const relation of Array.isArray(relations) ? relations : []) {
2027
+ const url = String(relation?.url || '');
2028
+ const match = /\/workItems\/(\d+)/i.exec(url);
2029
+ if (!match) continue;
2030
+ const id = Number(match[1]);
2031
+ if (Number.isFinite(id) && id > 0) out.add(id);
2032
+ }
2033
+ return [...out].sort((a, b) => a - b);
2034
+ }
2035
+
1138
2036
  private extractMewpRequirementIdentifier(fields: Record<string, any>, fallbackWorkItemId: number): string {
1139
2037
  const entries = Object.entries(fields || {});
1140
2038
 
@@ -1154,7 +2052,7 @@ ORDER BY [System.Id]`;
1154
2052
 
1155
2053
  const valueAsString = this.toMewpComparableText(value);
1156
2054
  if (!valueAsString) continue;
1157
- const normalized = this.normalizeMewpRequirementCode(valueAsString);
2055
+ const normalized = this.normalizeMewpRequirementCodeWithSuffix(valueAsString);
1158
2056
  if (normalized) return normalized;
1159
2057
  }
1160
2058
 
@@ -1166,19 +2064,29 @@ ORDER BY [System.Id]`;
1166
2064
 
1167
2065
  const valueAsString = this.toMewpComparableText(value);
1168
2066
  if (!valueAsString) continue;
1169
- const normalized = this.normalizeMewpRequirementCode(valueAsString);
2067
+ const normalized = this.normalizeMewpRequirementCodeWithSuffix(valueAsString);
1170
2068
  if (normalized) return normalized;
1171
2069
  }
1172
2070
 
1173
2071
  // Optional fallback from title only (avoid scanning all fields and accidental SR matches).
1174
2072
  const title = this.toMewpComparableText(fields?.['System.Title']);
1175
- const titleCode = this.normalizeMewpRequirementCode(title);
2073
+ const titleCode = this.normalizeMewpRequirementCodeWithSuffix(title);
1176
2074
  if (titleCode) return titleCode;
1177
2075
 
1178
- return String(fallbackWorkItemId || '');
2076
+ return fallbackWorkItemId ? `SR${fallbackWorkItemId}` : '';
1179
2077
  }
1180
2078
 
1181
2079
  private deriveMewpResponsibility(fields: Record<string, any>): string {
2080
+ const explicitSapWbs = this.toMewpComparableText(fields?.['Custom.SAPWBS']);
2081
+ const fromExplicitSapWbs = this.resolveMewpResponsibility(explicitSapWbs);
2082
+ if (fromExplicitSapWbs) return fromExplicitSapWbs;
2083
+ if (explicitSapWbs) return explicitSapWbs;
2084
+
2085
+ const explicitSapWbsByLabel = this.toMewpComparableText(fields?.['SAPWBS']);
2086
+ const fromExplicitLabel = this.resolveMewpResponsibility(explicitSapWbsByLabel);
2087
+ if (fromExplicitLabel) return fromExplicitLabel;
2088
+ if (explicitSapWbsByLabel) return explicitSapWbsByLabel;
2089
+
1182
2090
  const areaPath = this.toMewpComparableText(fields?.['System.AreaPath']);
1183
2091
  const fromAreaPath = this.resolveMewpResponsibility(areaPath);
1184
2092
  if (fromAreaPath) return fromAreaPath;
@@ -1194,17 +2102,69 @@ ORDER BY [System.Id]`;
1194
2102
  return '';
1195
2103
  }
1196
2104
 
2105
+ private deriveMewpSubSystem(fields: Record<string, any>): string {
2106
+ const directCandidates = [
2107
+ fields?.['Custom.SubSystem'],
2108
+ fields?.['Custom.Subsystem'],
2109
+ fields?.['SubSystem'],
2110
+ fields?.['Subsystem'],
2111
+ fields?.['subSystem'],
2112
+ ];
2113
+ for (const candidate of directCandidates) {
2114
+ const value = this.toMewpComparableText(candidate);
2115
+ if (value) return value;
2116
+ }
2117
+
2118
+ const keyHints = ['subsystem', 'sub system', 'sub_system'];
2119
+ for (const [key, value] of Object.entries(fields || {})) {
2120
+ const normalizedKey = String(key || '').toLowerCase();
2121
+ if (!keyHints.some((hint) => normalizedKey.includes(hint))) continue;
2122
+ const resolved = this.toMewpComparableText(value);
2123
+ if (resolved) return resolved;
2124
+ }
2125
+
2126
+ return '';
2127
+ }
2128
+
2129
+ private resolveBugResponsibility(fields: Record<string, any>): string {
2130
+ const sapWbsRaw = this.toMewpComparableText(fields?.['Custom.SAPWBS'] || fields?.['SAPWBS']);
2131
+ const fromSapWbs = this.resolveMewpResponsibility(sapWbsRaw);
2132
+ if (fromSapWbs === 'ESUK') return 'ESUK';
2133
+ if (fromSapWbs === 'IL') return 'Elisra';
2134
+
2135
+ const areaPathRaw = this.toMewpComparableText(fields?.['System.AreaPath']);
2136
+ const fromAreaPath = this.resolveMewpResponsibility(areaPathRaw);
2137
+ if (fromAreaPath === 'ESUK') return 'ESUK';
2138
+ if (fromAreaPath === 'IL') return 'Elisra';
2139
+
2140
+ return 'Unknown';
2141
+ }
2142
+
1197
2143
  private resolveMewpResponsibility(value: string): string {
1198
- const text = String(value || '')
1199
- .trim()
1200
- .toLowerCase();
1201
- if (!text) return '';
2144
+ const raw = String(value || '').trim();
2145
+ if (!raw) return '';
2146
+
2147
+ const rawUpper = raw.toUpperCase();
2148
+ if (rawUpper === 'ESUK') return 'ESUK';
2149
+ if (rawUpper === 'IL') return 'IL';
2150
+
2151
+ const normalizedPath = raw
2152
+ .toLowerCase()
2153
+ .replace(/\//g, '\\')
2154
+ .replace(/\\+/g, '\\')
2155
+ .trim();
2156
+
2157
+ if (normalizedPath.endsWith('\\atp\\esuk') || normalizedPath === 'atp\\esuk') return 'ESUK';
2158
+ if (normalizedPath.endsWith('\\atp') || normalizedPath === 'atp') return 'IL';
1202
2159
 
1203
- if (/(^|[^a-z0-9])esuk([^a-z0-9]|$)/i.test(text)) return 'ESUK';
1204
- if (/(^|[^a-z0-9])il([^a-z0-9]|$)/i.test(text)) return 'IL';
1205
2160
  return '';
1206
2161
  }
1207
2162
 
2163
+ private isExcludedL3L4BySapWbs(value: string): boolean {
2164
+ const responsibility = this.resolveMewpResponsibility(this.toMewpComparableText(value));
2165
+ return responsibility === 'ESUK';
2166
+ }
2167
+
1208
2168
  private normalizeMewpRequirementCode(value: string): string {
1209
2169
  const text = String(value || '').trim();
1210
2170
  if (!text) return '';
@@ -1213,6 +2173,16 @@ ORDER BY [System.Id]`;
1213
2173
  return `SR${match[1]}`;
1214
2174
  }
1215
2175
 
2176
+ private normalizeMewpRequirementCodeWithSuffix(value: string): string {
2177
+ const text = String(value || '').trim();
2178
+ if (!text) return '';
2179
+ const compact = text.replace(/\s+/g, '');
2180
+ const match = /^SR(\d+)(?:-(\d+))?$/i.exec(compact);
2181
+ if (!match) return '';
2182
+ if (match[2]) return `SR${match[1]}-${match[2]}`;
2183
+ return `SR${match[1]}`;
2184
+ }
2185
+
1216
2186
  private toMewpComparableText(value: any): string {
1217
2187
  if (value === null || value === undefined) return '';
1218
2188
  if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
@@ -1225,6 +2195,8 @@ ORDER BY [System.Id]`;
1225
2195
  if (name) return String(name).trim();
1226
2196
  const uniqueName = (value as any).uniqueName;
1227
2197
  if (uniqueName) return String(uniqueName).trim();
2198
+ const objectValue = (value as any).value;
2199
+ if (objectValue !== undefined && objectValue !== null) return String(objectValue).trim();
1228
2200
  }
1229
2201
  return String(value).trim();
1230
2202
  }