@elisra-devops/docgen-data-provider 1.75.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
  /**
@@ -393,20 +431,53 @@ export default class ResultDataProvider {
393
431
  public async getMewpL2CoverageFlatResults(
394
432
  testPlanId: string,
395
433
  projectName: string,
396
- selectedSuiteIds: number[] | undefined
397
- ) {
398
- const defaultPayload = {
434
+ selectedSuiteIds: number[] | undefined,
435
+ linkedQueryRequest?: any,
436
+ options?: MewpCoverageRequestOptions
437
+ ): Promise<MewpCoverageFlatPayload> {
438
+ const defaultPayload: MewpCoverageFlatPayload = {
399
439
  sheetName: `MEWP L2 Coverage - Plan ${testPlanId}`,
400
440
  columnOrder: [...ResultDataProvider.MEWP_L2_COVERAGE_COLUMNS],
401
- rows: [] as any[],
441
+ rows: [],
402
442
  };
403
443
 
404
444
  try {
405
445
  const planName = await this.fetchTestPlanName(testPlanId, projectName);
406
- const suites = await this.fetchTestSuites(testPlanId, projectName, selectedSuiteIds, true);
407
- 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
+ );
452
+
453
+ const allRequirements = await this.fetchMewpL2Requirements(projectName);
454
+ if (allRequirements.length === 0) {
455
+ return {
456
+ ...defaultPayload,
457
+ sheetName: this.buildMewpCoverageSheetName(planName, testPlanId),
458
+ };
459
+ }
408
460
 
409
- const requirements = await this.fetchMewpL2Requirements(projectName);
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
+ );
410
481
  if (requirements.length === 0) {
411
482
  return {
412
483
  ...defaultPayload,
@@ -414,47 +485,40 @@ export default class ResultDataProvider {
414
485
  };
415
486
  }
416
487
 
417
- const requirementIndex = new Map<string, Map<number, { passed: number; failed: number; notRun: number }>>();
488
+ const requirementIndex: MewpRequirementIndex = new Map();
418
489
  const observedTestCaseIdsByRequirement = new Map<string, Set<number>>();
419
490
  const requirementKeys = new Set<string>();
420
491
  requirements.forEach((requirement) => {
421
- const key = this.toRequirementKey(requirement.requirementId);
492
+ const key = String(requirement?.baseKey || '').trim();
422
493
  if (!key) return;
423
494
  requirementKeys.add(key);
424
495
  });
425
496
 
426
497
  const parsedDefinitionStepsByTestCase = new Map<number, TestSteps[]>();
427
498
  const testCaseStepsXmlMap = this.buildTestCaseStepsXmlMap(testData);
428
- const testCaseTitleMap = this.buildMewpTestCaseTitleMap(testData);
429
-
430
499
  const runResults = await this.fetchAllResultDataTestReporter(testData, projectName, [], false, false);
431
500
  for (const runResult of runResults) {
432
501
  const testCaseId = this.extractMewpTestCaseId(runResult);
433
- const runTestCaseTitle = this.toMewpComparableText(
434
- runResult?.testCase?.name || runResult?.testCaseName || runResult?.testCaseTitle
435
- );
436
- if (Number.isFinite(testCaseId) && testCaseId > 0 && runTestCaseTitle && !testCaseTitleMap.has(testCaseId)) {
437
- testCaseTitleMap.set(testCaseId, runTestCaseTitle);
438
- }
439
- const actionResults = Array.isArray(runResult?.iteration?.actionResults)
440
- ? runResult.iteration.actionResults
502
+ const rawActionResults = Array.isArray(runResult?.iteration?.actionResults)
503
+ ? runResult.iteration.actionResults.filter((item: any) => !item?.isSharedStepTitle)
441
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
+ );
442
511
  const hasExecutedRun =
443
512
  Number(runResult?.lastRunId || 0) > 0 && Number(runResult?.lastResultId || 0) > 0;
444
513
 
445
514
  if (actionResults.length > 0) {
446
- for (const actionResult of actionResults) {
447
- if (actionResult?.isSharedStepTitle) continue;
448
- const stepStatus = this.classifyRequirementStepOutcome(actionResult?.outcome);
449
- this.accumulateRequirementCountsFromStepText(
450
- `${String(actionResult?.action || '')} ${String(actionResult?.expected || '')}`,
451
- stepStatus,
452
- testCaseId,
453
- requirementKeys,
454
- requirementIndex,
455
- observedTestCaseIdsByRequirement
456
- );
457
- }
515
+ this.accumulateRequirementCountsFromActionResults(
516
+ actionResults,
517
+ testCaseId,
518
+ requirementKeys,
519
+ requirementIndex,
520
+ observedTestCaseIdsByRequirement
521
+ );
458
522
  continue;
459
523
  }
460
524
 
@@ -475,24 +539,33 @@ export default class ResultDataProvider {
475
539
  }
476
540
 
477
541
  const definitionSteps = parsedDefinitionStepsByTestCase.get(testCaseId) || [];
478
- for (const step of definitionSteps) {
479
- if (step?.isSharedStepTitle) continue;
480
- this.accumulateRequirementCountsFromStepText(
481
- `${String(step?.action || '')} ${String(step?.expected || '')}`,
482
- 'notRun',
483
- testCaseId,
484
- requirementKeys,
485
- requirementIndex,
486
- observedTestCaseIdsByRequirement
487
- );
488
- }
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
+ );
489
560
  }
490
561
 
491
562
  const rows = this.buildMewpCoverageRows(
492
563
  requirements,
493
564
  requirementIndex,
494
565
  observedTestCaseIdsByRequirement,
495
- testCaseTitleMap
566
+ linkedRequirementsByTestCase,
567
+ externalL3L4ByBaseKey,
568
+ externalBugsByTestCase
496
569
  );
497
570
 
498
571
  return {
@@ -502,10 +575,261 @@ export default class ResultDataProvider {
502
575
  };
503
576
  } catch (error: any) {
504
577
  logger.error(`Error during getMewpL2CoverageFlatResults: ${error.message}`);
578
+ if (error instanceof MewpExternalFileValidationError) {
579
+ throw error;
580
+ }
581
+ return defaultPayload;
582
+ }
583
+ }
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}`);
505
774
  return defaultPayload;
506
775
  }
507
776
  }
508
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
+
509
833
  /**
510
834
  * Mapping each attachment to a proper URL for downloading it
511
835
  * @param runResults Array of run results
@@ -549,33 +873,63 @@ export default class ResultDataProvider {
549
873
  return `MEWP L2 Coverage - ${suffix}`;
550
874
  }
551
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
+
552
881
  private createMewpCoverageRow(
553
- requirement: {
554
- requirementId: string;
555
- title: string;
556
- responsibility: string;
557
- },
558
- testCaseId: number | undefined,
559
- testCaseTitle: string,
560
- stepSummary: { passed: number; failed: number; notRun: number }
561
- ) {
562
- const customerId = this.formatMewpCustomerId(requirement.requirementId);
563
- const customerTitle = this.toMewpComparableText(requirement.title);
564
- const responsibility = this.toMewpComparableText(requirement.responsibility);
565
- 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);
566
890
 
567
891
  return {
568
- 'Customer ID': customerId,
569
- 'Title (Customer name)': customerTitle,
570
- 'Responsibility - SAPWBS (ESUK/IL)': responsibility,
571
- 'Test case id': safeTestCaseId,
572
- 'Test case title': String(testCaseTitle || '').trim(),
573
- 'Number of passed steps': Number.isFinite(stepSummary?.passed) ? stepSummary.passed : 0,
574
- 'Number of failed steps': Number.isFinite(stepSummary?.failed) ? stepSummary.failed : 0,
575
- '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(),
576
903
  };
577
904
  }
578
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
+
579
933
  private formatMewpCustomerId(rawValue: string): string {
580
934
  const normalized = this.normalizeMewpRequirementCode(this.toMewpComparableText(rawValue));
581
935
  if (normalized) return normalized;
@@ -586,51 +940,109 @@ export default class ResultDataProvider {
586
940
  }
587
941
 
588
942
  private buildMewpCoverageRows(
589
- requirements: Array<{
590
- requirementId: string;
591
- title: string;
592
- responsibility: string;
593
- linkedTestCaseIds: number[];
594
- }>,
595
- requirementIndex: Map<string, Map<number, { passed: number; failed: number; notRun: number }>>,
943
+ requirements: MewpL2RequirementFamily[],
944
+ requirementIndex: MewpRequirementIndex,
596
945
  observedTestCaseIdsByRequirement: Map<string, Set<number>>,
597
- testCaseTitleMap: Map<number, string>
598
- ): any[] {
599
- 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);
600
952
  for (const requirement of requirements) {
601
- const key = this.toRequirementKey(requirement.requirementId);
953
+ const key = String(requirement?.baseKey || this.toRequirementKey(requirement.requirementId) || '').trim();
602
954
  const linkedTestCaseIds = (requirement?.linkedTestCaseIds || []).filter(
603
955
  (id) => Number.isFinite(id) && Number(id) > 0
604
956
  );
957
+ const linkedByTestCase = key ? Array.from(linkedByRequirement.get(key) || []) : [];
605
958
  const observedTestCaseIds = key
606
959
  ? Array.from(observedTestCaseIdsByRequirement.get(key) || [])
607
960
  : [];
608
961
 
609
- const testCaseIds = Array.from(new Set<number>([...linkedTestCaseIds, ...observedTestCaseIds])).sort(
610
- (a, b) => a - b
611
- );
962
+ const testCaseIds = Array.from(
963
+ new Set<number>([...linkedTestCaseIds, ...linkedByTestCase, ...observedTestCaseIds])
964
+ ).sort((a, b) => a - b);
612
965
 
613
- if (testCaseIds.length === 0) {
614
- rows.push(
615
- this.createMewpCoverageRow(requirement, undefined, '', {
616
- passed: 0,
617
- failed: 0,
618
- notRun: 0,
619
- })
620
- );
621
- continue;
622
- }
966
+ let totalPassed = 0;
967
+ let totalFailed = 0;
968
+ let totalNotRun = 0;
969
+ const aggregatedBugs = new Map<number, MewpBugLink>();
623
970
 
624
971
  for (const testCaseId of testCaseIds) {
625
972
  const summary = key
626
973
  ? requirementIndex.get(key)?.get(testCaseId) || { passed: 0, failed: 0, notRun: 0 }
627
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) {
628
1017
  rows.push(
629
1018
  this.createMewpCoverageRow(
630
1019
  requirement,
631
- testCaseId,
632
- String(testCaseTitleMap.get(testCaseId) || ''),
633
- 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
634
1046
  )
635
1047
  );
636
1048
  }
@@ -639,6 +1051,262 @@ export default class ResultDataProvider {
639
1051
  return rows;
640
1052
  }
641
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
+
642
1310
  private buildMewpTestCaseTitleMap(testData: any[]): Map<number, string> {
643
1311
  const map = new Map<number, string>();
644
1312
 
@@ -736,56 +1404,197 @@ export default class ResultDataProvider {
736
1404
  return 'notRun';
737
1405
  }
738
1406
 
739
- private accumulateRequirementCountsFromStepText(
740
- stepText: string,
741
- status: 'passed' | 'failed' | 'notRun',
1407
+ private accumulateRequirementCountsFromActionResults(
1408
+ actionResults: any[],
742
1409
  testCaseId: number,
743
1410
  requirementKeys: Set<string>,
744
1411
  counters: Map<string, Map<number, { passed: number; failed: number; notRun: number }>>,
745
1412
  observedTestCaseIdsByRequirement: Map<string, Set<number>>
746
1413
  ) {
747
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
+ }
748
1437
 
749
- const codes = this.extractRequirementCodesFromText(stepText);
750
- for (const code of codes) {
751
- if (requirementKeys.size > 0 && !requirementKeys.has(code)) continue;
752
- if (!counters.has(code)) {
753
- counters.set(code, new Map<number, { passed: number; failed: number; notRun: number }>());
754
- }
755
- const perTestCaseCounters = counters.get(code)!;
756
- if (!perTestCaseCounters.has(testCaseId)) {
757
- perTestCaseCounters.set(testCaseId, { passed: 0, failed: 0, notRun: 0 });
758
- }
1438
+ if (!observedTestCaseIdsByRequirement.has(code)) {
1439
+ observedTestCaseIdsByRequirement.set(code, new Set<number>());
1440
+ }
1441
+ observedTestCaseIdsByRequirement.get(code)!.add(testCaseId);
759
1442
 
760
- if (!observedTestCaseIdsByRequirement.has(code)) {
761
- 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;
762
1447
  }
763
- observedTestCaseIdsByRequirement.get(code)!.add(testCaseId);
1448
+ }
1449
+ }
764
1450
 
765
- const counter = perTestCaseCounters.get(testCaseId)!;
766
- if (status === 'passed') counter.passed += 1;
767
- else if (status === 'failed') counter.failed += 1;
768
- 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;
769
1461
  }
1462
+ return hasNotRun ? 'notRun' : 'passed';
770
1463
  }
771
1464
 
772
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> {
773
1499
  const out = new Set<string>();
774
1500
  const source = this.normalizeRequirementStepText(text);
775
- // Supports SR<ID> patterns even when HTML formatting breaks the token,
776
- // e.g. "S<b>R</b> 0 0 1" or "S R 0 0 1".
777
- const regex = /S[\s\u00A0]*R(?:[\s\u00A0\-_]*\d){1,12}/gi;
778
- let match: RegExpExecArray | null = null;
779
- while ((match = regex.exec(source)) !== null) {
780
- const digitsOnly = String(match[0] || '').replace(/\D/g, '');
781
- const digits = Number.parseInt(digitsOnly, 10);
782
- if (Number.isFinite(digits)) {
783
- 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
+ }
784
1519
  }
785
1520
  }
1521
+
786
1522
  return out;
787
1523
  }
788
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
+
789
1598
  private normalizeRequirementStepText(text: string): string {
790
1599
  const raw = String(text || '');
791
1600
  if (!raw) return '';
@@ -802,23 +1611,19 @@ export default class ResultDataProvider {
802
1611
  .replace(/\s+/g, ' ');
803
1612
  }
804
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
+
805
1622
  private toRequirementKey(requirementId: string): string {
806
- const normalized = this.normalizeMewpRequirementCode(requirementId);
807
- if (!normalized) return '';
808
- const digits = Number.parseInt(normalized.replace(/^SR/i, ''), 10);
809
- if (!Number.isFinite(digits)) return '';
810
- return `SR${digits}`;
811
- }
812
-
813
- private async fetchMewpL2Requirements(projectName: string): Promise<
814
- Array<{
815
- workItemId: number;
816
- requirementId: string;
817
- title: string;
818
- responsibility: string;
819
- linkedTestCaseIds: number[];
820
- }>
821
- > {
1623
+ return this.normalizeMewpRequirementCode(requirementId);
1624
+ }
1625
+
1626
+ private async fetchMewpL2Requirements(projectName: string): Promise<MewpL2RequirementWorkItem[]> {
822
1627
  const workItemTypeNames = await this.fetchMewpRequirementTypeNames(projectName);
823
1628
  if (workItemTypeNames.length === 0) {
824
1629
  return [];
@@ -827,17 +1632,36 @@ export default class ResultDataProvider {
827
1632
  const quotedTypeNames = workItemTypeNames
828
1633
  .map((name) => `'${String(name).replace(/'/g, "''")}'`)
829
1634
  .join(', ');
830
- const wiql = `SELECT [System.Id]
831
- FROM WorkItems
832
- WHERE [System.TeamProject] = @project
833
- AND [System.WorkItemType] IN (${quotedTypeNames})
834
- ORDER BY [System.Id]`;
835
- const wiqlUrl = `${this.orgUrl}${projectName}/_apis/wit/wiql?api-version=7.1-preview.2`;
836
- const wiqlResponse = await TFSServices.postRequest(wiqlUrl, this.token, 'Post', { query: wiql }, null);
837
- const workItemRefs = Array.isArray(wiqlResponse?.data?.workItems) ? wiqlResponse.data.workItems : [];
838
- const requirementIds = workItemRefs
839
- .map((item: any) => Number(item?.id))
840
- .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
+ }
841
1665
 
842
1666
  if (requirementIds.length === 0) {
843
1667
  return [];
@@ -846,16 +1670,294 @@ ORDER BY [System.Id]`;
846
1670
  const workItems = await this.fetchWorkItemsByIds(projectName, requirementIds, true);
847
1671
  const requirements = workItems.map((wi: any) => {
848
1672
  const fields = wi?.fields || {};
1673
+ const requirementId = this.extractMewpRequirementIdentifier(fields, Number(wi?.id || 0));
1674
+ const areaPath = this.toMewpComparableText(fields?.['System.AreaPath']);
849
1675
  return {
850
1676
  workItemId: Number(wi?.id || 0),
851
- requirementId: this.extractMewpRequirementIdentifier(fields, Number(wi?.id || 0)),
1677
+ requirementId,
1678
+ baseKey: this.toRequirementKey(requirementId),
852
1679
  title: this.toMewpComparableText(fields?.['System.Title'] || wi?.title),
1680
+ subSystem: this.deriveMewpSubSystem(fields),
853
1681
  responsibility: this.deriveMewpResponsibility(fields),
854
1682
  linkedTestCaseIds: this.extractLinkedTestCaseIdsFromRequirement(wi?.relations || []),
1683
+ relatedWorkItemIds: this.extractLinkedWorkItemIdsFromRelations(wi?.relations || []),
1684
+ areaPath,
855
1685
  };
856
1686
  });
857
1687
 
858
- 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)));
1695
+ }
1696
+
1697
+ private isMewpL2AreaPath(areaPath: string): boolean {
1698
+ const normalized = String(areaPath || '')
1699
+ .trim()
1700
+ .toLowerCase()
1701
+ .replace(/\//g, '\\');
1702
+ if (!normalized) return false;
1703
+ return normalized.includes('\\customer requirements\\level 2');
1704
+ }
1705
+
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
+ >();
1718
+
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
+ };
1733
+
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;
1738
+
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
+ }
1758
+
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
+ }
1770
+
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
+ }
1787
+
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
+ });
1803
+ }
1804
+ return map.get(testCaseId)!;
1805
+ };
1806
+
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
+ }
1815
+
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
+ }
1824
+
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
+ }
1838
+
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);
1859
+ }
1860
+ }
1861
+ }
1862
+ }
1863
+
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);
1874
+ }
1875
+
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
+ }
1885
+ }
1886
+
1887
+ return map;
1888
+ }
1889
+
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
+ }
1909
+ }
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;
1920
+
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);
1926
+ }
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));
1938
+ }
1939
+
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
+ }
1945
+ }
1946
+
1947
+ private isTestCaseToRequirementRelation(relation: any): boolean {
1948
+ const rel = String(relation?.rel || '')
1949
+ .trim()
1950
+ .toLowerCase();
1951
+ if (!rel) return false;
1952
+ return rel.includes('testedby-reverse') || (rel.includes('tests') && rel.includes('reverse'));
1953
+ }
1954
+
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;
859
1961
  }
860
1962
 
861
1963
  private async fetchMewpRequirementTypeNames(projectName: string): Promise<string[]> {
@@ -919,6 +2021,18 @@ ORDER BY [System.Id]`;
919
2021
  return [...out].sort((a, b) => a - b);
920
2022
  }
921
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
+
922
2036
  private extractMewpRequirementIdentifier(fields: Record<string, any>, fallbackWorkItemId: number): string {
923
2037
  const entries = Object.entries(fields || {});
924
2038
 
@@ -938,7 +2052,7 @@ ORDER BY [System.Id]`;
938
2052
 
939
2053
  const valueAsString = this.toMewpComparableText(value);
940
2054
  if (!valueAsString) continue;
941
- const normalized = this.normalizeMewpRequirementCode(valueAsString);
2055
+ const normalized = this.normalizeMewpRequirementCodeWithSuffix(valueAsString);
942
2056
  if (normalized) return normalized;
943
2057
  }
944
2058
 
@@ -950,13 +2064,13 @@ ORDER BY [System.Id]`;
950
2064
 
951
2065
  const valueAsString = this.toMewpComparableText(value);
952
2066
  if (!valueAsString) continue;
953
- const normalized = this.normalizeMewpRequirementCode(valueAsString);
2067
+ const normalized = this.normalizeMewpRequirementCodeWithSuffix(valueAsString);
954
2068
  if (normalized) return normalized;
955
2069
  }
956
2070
 
957
2071
  // Optional fallback from title only (avoid scanning all fields and accidental SR matches).
958
2072
  const title = this.toMewpComparableText(fields?.['System.Title']);
959
- const titleCode = this.normalizeMewpRequirementCode(title);
2073
+ const titleCode = this.normalizeMewpRequirementCodeWithSuffix(title);
960
2074
  if (titleCode) return titleCode;
961
2075
 
962
2076
  return fallbackWorkItemId ? `SR${fallbackWorkItemId}` : '';
@@ -988,17 +2102,69 @@ ORDER BY [System.Id]`;
988
2102
  return '';
989
2103
  }
990
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
+
991
2143
  private resolveMewpResponsibility(value: string): string {
992
- const text = String(value || '')
993
- .trim()
994
- .toLowerCase();
995
- 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';
996
2159
 
997
- if (/(^|[^a-z0-9])esuk([^a-z0-9]|$)/i.test(text)) return 'ESUK';
998
- if (/(^|[^a-z0-9])il([^a-z0-9]|$)/i.test(text)) return 'IL';
999
2160
  return '';
1000
2161
  }
1001
2162
 
2163
+ private isExcludedL3L4BySapWbs(value: string): boolean {
2164
+ const responsibility = this.resolveMewpResponsibility(this.toMewpComparableText(value));
2165
+ return responsibility === 'ESUK';
2166
+ }
2167
+
1002
2168
  private normalizeMewpRequirementCode(value: string): string {
1003
2169
  const text = String(value || '').trim();
1004
2170
  if (!text) return '';
@@ -1007,6 +2173,16 @@ ORDER BY [System.Id]`;
1007
2173
  return `SR${match[1]}`;
1008
2174
  }
1009
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
+
1010
2186
  private toMewpComparableText(value: any): string {
1011
2187
  if (value === null || value === undefined) return '';
1012
2188
  if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {