@elisra-devops/docgen-data-provider 1.75.0 → 1.77.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,92 @@ 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
+ );
481
+ const hasExternalBugsFile = !!String(
482
+ options?.externalBugsFile?.name ||
483
+ options?.externalBugsFile?.objectName ||
484
+ options?.externalBugsFile?.text ||
485
+ options?.externalBugsFile?.url ||
486
+ ''
487
+ ).trim();
488
+ const hasExternalL3L4File = !!String(
489
+ options?.externalL3L4File?.name ||
490
+ options?.externalL3L4File?.objectName ||
491
+ options?.externalL3L4File?.text ||
492
+ options?.externalL3L4File?.url ||
493
+ ''
494
+ ).trim();
495
+ const externalBugLinksCount = [...externalBugsByTestCase.values()].reduce(
496
+ (sum, items) => sum + (Array.isArray(items) ? items.length : 0),
497
+ 0
498
+ );
499
+ const externalL3L4LinksCount = [...externalL3L4ByBaseKey.values()].reduce(
500
+ (sum, items) => sum + (Array.isArray(items) ? items.length : 0),
501
+ 0
502
+ );
503
+ logger.info(
504
+ `MEWP coverage external ingestion summary: ` +
505
+ `bugsFileProvided=${hasExternalBugsFile} bugsTestCases=${externalBugsByTestCase.size} bugsLinks=${externalBugLinksCount}; ` +
506
+ `l3l4FileProvided=${hasExternalL3L4File} l3l4BaseKeys=${externalL3L4ByBaseKey.size} l3l4Links=${externalL3L4LinksCount}`
507
+ );
508
+ if (hasExternalBugsFile && externalBugLinksCount === 0) {
509
+ logger.warn(
510
+ `MEWP coverage: external bugs file was provided but produced 0 links. ` +
511
+ `Check SR/test-case/state values in ingestion logs.`
512
+ );
513
+ }
514
+ if (hasExternalL3L4File && externalL3L4LinksCount === 0) {
515
+ logger.warn(
516
+ `MEWP coverage: external L3/L4 file was provided but produced 0 links. ` +
517
+ `Check SR/AREA34/state/SAPWBS filters in ingestion logs.`
518
+ );
519
+ }
410
520
  if (requirements.length === 0) {
411
521
  return {
412
522
  ...defaultPayload,
@@ -414,47 +524,40 @@ export default class ResultDataProvider {
414
524
  };
415
525
  }
416
526
 
417
- const requirementIndex = new Map<string, Map<number, { passed: number; failed: number; notRun: number }>>();
527
+ const requirementIndex: MewpRequirementIndex = new Map();
418
528
  const observedTestCaseIdsByRequirement = new Map<string, Set<number>>();
419
529
  const requirementKeys = new Set<string>();
420
530
  requirements.forEach((requirement) => {
421
- const key = this.toRequirementKey(requirement.requirementId);
531
+ const key = String(requirement?.baseKey || '').trim();
422
532
  if (!key) return;
423
533
  requirementKeys.add(key);
424
534
  });
425
535
 
426
536
  const parsedDefinitionStepsByTestCase = new Map<number, TestSteps[]>();
427
537
  const testCaseStepsXmlMap = this.buildTestCaseStepsXmlMap(testData);
428
- const testCaseTitleMap = this.buildMewpTestCaseTitleMap(testData);
429
-
430
538
  const runResults = await this.fetchAllResultDataTestReporter(testData, projectName, [], false, false);
431
539
  for (const runResult of runResults) {
432
540
  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
541
+ const rawActionResults = Array.isArray(runResult?.iteration?.actionResults)
542
+ ? runResult.iteration.actionResults.filter((item: any) => !item?.isSharedStepTitle)
441
543
  : [];
544
+ const actionResults = rawActionResults.sort((a: any, b: any) =>
545
+ this.compareActionResults(
546
+ String(a?.stepPosition || a?.stepIdentifier || ''),
547
+ String(b?.stepPosition || b?.stepIdentifier || '')
548
+ )
549
+ );
442
550
  const hasExecutedRun =
443
551
  Number(runResult?.lastRunId || 0) > 0 && Number(runResult?.lastResultId || 0) > 0;
444
552
 
445
553
  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
- }
554
+ this.accumulateRequirementCountsFromActionResults(
555
+ actionResults,
556
+ testCaseId,
557
+ requirementKeys,
558
+ requirementIndex,
559
+ observedTestCaseIdsByRequirement
560
+ );
458
561
  continue;
459
562
  }
460
563
 
@@ -475,24 +578,51 @@ export default class ResultDataProvider {
475
578
  }
476
579
 
477
580
  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
- }
581
+ const fallbackActionResults = definitionSteps
582
+ .filter((step) => !step?.isSharedStepTitle)
583
+ .sort((a, b) =>
584
+ this.compareActionResults(String(a?.stepPosition || ''), String(b?.stepPosition || ''))
585
+ )
586
+ .map((step) => ({
587
+ stepPosition: step?.stepPosition,
588
+ expected: step?.expected,
589
+ outcome: 'Unspecified',
590
+ }));
591
+
592
+ this.accumulateRequirementCountsFromActionResults(
593
+ fallbackActionResults,
594
+ testCaseId,
595
+ requirementKeys,
596
+ requirementIndex,
597
+ observedTestCaseIdsByRequirement
598
+ );
489
599
  }
490
600
 
491
601
  const rows = this.buildMewpCoverageRows(
492
602
  requirements,
493
603
  requirementIndex,
494
604
  observedTestCaseIdsByRequirement,
495
- testCaseTitleMap
605
+ linkedRequirementsByTestCase,
606
+ externalL3L4ByBaseKey,
607
+ externalBugsByTestCase
608
+ );
609
+ const coverageRowStats = rows.reduce(
610
+ (acc, row) => {
611
+ const hasBug = Number(row?.['Bug ID'] || 0) > 0;
612
+ const hasL3 = String(row?.['L3 REQ ID'] || '').trim() !== '';
613
+ const hasL4 = String(row?.['L4 REQ ID'] || '').trim() !== '';
614
+ if (hasBug) acc.bugRows += 1;
615
+ if (hasL3) acc.l3Rows += 1;
616
+ if (hasL4) acc.l4Rows += 1;
617
+ if (!hasBug && !hasL3 && !hasL4) acc.baseOnlyRows += 1;
618
+ return acc;
619
+ },
620
+ { bugRows: 0, l3Rows: 0, l4Rows: 0, baseOnlyRows: 0 }
621
+ );
622
+ logger.info(
623
+ `MEWP coverage output summary: requirements=${requirements.length} rows=${rows.length} ` +
624
+ `bugRows=${coverageRowStats.bugRows} l3Rows=${coverageRowStats.l3Rows} ` +
625
+ `l4Rows=${coverageRowStats.l4Rows} baseOnlyRows=${coverageRowStats.baseOnlyRows}`
496
626
  );
497
627
 
498
628
  return {
@@ -502,10 +632,261 @@ export default class ResultDataProvider {
502
632
  };
503
633
  } catch (error: any) {
504
634
  logger.error(`Error during getMewpL2CoverageFlatResults: ${error.message}`);
635
+ if (error instanceof MewpExternalFileValidationError) {
636
+ throw error;
637
+ }
638
+ return defaultPayload;
639
+ }
640
+ }
641
+
642
+ public async getMewpInternalValidationFlatResults(
643
+ testPlanId: string,
644
+ projectName: string,
645
+ selectedSuiteIds: number[] | undefined,
646
+ linkedQueryRequest?: any,
647
+ options?: MewpInternalValidationRequestOptions
648
+ ): Promise<MewpInternalValidationFlatPayload> {
649
+ const defaultPayload: MewpInternalValidationFlatPayload = {
650
+ sheetName: `MEWP Internal Validation - Plan ${testPlanId}`,
651
+ columnOrder: [...ResultDataProvider.INTERNAL_VALIDATION_COLUMNS],
652
+ rows: [],
653
+ };
654
+
655
+ try {
656
+ const planName = await this.fetchTestPlanName(testPlanId, projectName);
657
+ const testData = await this.fetchMewpScopedTestData(
658
+ testPlanId,
659
+ projectName,
660
+ selectedSuiteIds,
661
+ !!options?.useRelFallback
662
+ );
663
+ const allRequirements = await this.fetchMewpL2Requirements(projectName);
664
+ const linkedRequirementsByTestCase = await this.buildLinkedRequirementsByTestCase(
665
+ allRequirements,
666
+ testData,
667
+ projectName
668
+ );
669
+ const scopedRequirementKeys = await this.resolveMewpRequirementScopeKeysFromQuery(
670
+ linkedQueryRequest,
671
+ allRequirements,
672
+ linkedRequirementsByTestCase
673
+ );
674
+ const requirementFamilies = this.buildRequirementFamilyMap(
675
+ allRequirements,
676
+ scopedRequirementKeys?.size ? scopedRequirementKeys : undefined
677
+ );
678
+
679
+ const rows: MewpInternalValidationRow[] = [];
680
+ const stepsXmlByTestCase = this.buildTestCaseStepsXmlMap(testData);
681
+ const testCaseTitleMap = this.buildMewpTestCaseTitleMap(testData);
682
+ const allTestCaseIds = new Set<number>();
683
+ for (const suite of testData || []) {
684
+ const testCasesItems = Array.isArray(suite?.testCasesItems) ? suite.testCasesItems : [];
685
+ for (const testCase of testCasesItems) {
686
+ const id = Number(testCase?.workItem?.id || testCase?.testCaseId || testCase?.id || 0);
687
+ if (Number.isFinite(id) && id > 0) allTestCaseIds.add(id);
688
+ }
689
+ const testPointsItems = Array.isArray(suite?.testPointsItems) ? suite.testPointsItems : [];
690
+ for (const testPoint of testPointsItems) {
691
+ const id = Number(testPoint?.testCaseId || testPoint?.testCase?.id || 0);
692
+ if (Number.isFinite(id) && id > 0) allTestCaseIds.add(id);
693
+ }
694
+ }
695
+
696
+ const validL2BaseKeys = new Set<string>([...requirementFamilies.keys()]);
697
+
698
+ for (const testCaseId of [...allTestCaseIds].sort((a, b) => a - b)) {
699
+ const stepsXml = stepsXmlByTestCase.get(testCaseId) || '';
700
+ const parsedSteps =
701
+ stepsXml && String(stepsXml).trim() !== ''
702
+ ? await this.testStepParserHelper.parseTestSteps(stepsXml, new Map<number, number>())
703
+ : [];
704
+ const mentionEntries = this.extractRequirementMentionsFromExpectedSteps(parsedSteps, true);
705
+ const mentionedL2Only = new Set<string>();
706
+ const mentionedCodeFirstStep = new Map<string, string>();
707
+ const mentionedBaseFirstStep = new Map<string, string>();
708
+ for (const mentionEntry of mentionEntries) {
709
+ const scopeFilteredCodes =
710
+ scopedRequirementKeys?.size && mentionEntry.codes.size > 0
711
+ ? [...mentionEntry.codes].filter((code) => scopedRequirementKeys.has(this.toRequirementKey(code)))
712
+ : [...mentionEntry.codes];
713
+ for (const code of scopeFilteredCodes) {
714
+ const baseKey = this.toRequirementKey(code);
715
+ if (!baseKey) continue;
716
+ if (validL2BaseKeys.has(baseKey)) {
717
+ mentionedL2Only.add(code);
718
+ if (!mentionedCodeFirstStep.has(code)) {
719
+ mentionedCodeFirstStep.set(code, mentionEntry.stepRef);
720
+ }
721
+ if (!mentionedBaseFirstStep.has(baseKey)) {
722
+ mentionedBaseFirstStep.set(baseKey, mentionEntry.stepRef);
723
+ }
724
+ }
725
+ }
726
+ }
727
+
728
+ const mentionedBaseKeys = new Set<string>(
729
+ [...mentionedL2Only].map((code) => this.toRequirementKey(code)).filter((code) => !!code)
730
+ );
731
+
732
+ const expectedFamilyCodes = new Set<string>();
733
+ for (const baseKey of mentionedBaseKeys) {
734
+ const familyCodes = requirementFamilies.get(baseKey);
735
+ if (familyCodes?.size) {
736
+ familyCodes.forEach((code) => expectedFamilyCodes.add(code));
737
+ } else {
738
+ for (const code of mentionedL2Only) {
739
+ if (this.toRequirementKey(code) === baseKey) expectedFamilyCodes.add(code);
740
+ }
741
+ }
742
+ }
743
+
744
+ const linkedFullCodesRaw = linkedRequirementsByTestCase.get(testCaseId)?.fullCodes || new Set<string>();
745
+ const linkedFullCodes =
746
+ scopedRequirementKeys?.size && linkedFullCodesRaw.size > 0
747
+ ? new Set<string>(
748
+ [...linkedFullCodesRaw].filter((code) =>
749
+ scopedRequirementKeys.has(this.toRequirementKey(code))
750
+ )
751
+ )
752
+ : linkedFullCodesRaw;
753
+ const linkedBaseKeys = new Set<string>(
754
+ [...linkedFullCodes].map((code) => this.toRequirementKey(code)).filter((code) => !!code)
755
+ );
756
+
757
+ const missingMentioned = [...mentionedL2Only].filter((code) => {
758
+ const baseKey = this.toRequirementKey(code);
759
+ if (!baseKey) return false;
760
+ const hasSpecificSuffix = /-\d+$/.test(code);
761
+ if (hasSpecificSuffix) return !linkedFullCodes.has(code);
762
+ return !linkedBaseKeys.has(baseKey);
763
+ });
764
+ const missingFamily = [...expectedFamilyCodes].filter((code) => !linkedFullCodes.has(code));
765
+ const extraLinked = [...linkedFullCodes].filter((code) => !expectedFamilyCodes.has(code));
766
+ const mentionedButNotLinkedByStep = new Map<string, Set<string>>();
767
+ const appendMentionedButNotLinked = (requirementId: string, stepRef: string) => {
768
+ const normalizedRequirementId = this.normalizeMewpRequirementCodeWithSuffix(requirementId);
769
+ if (!normalizedRequirementId) return;
770
+ const normalizedStepRef = String(stepRef || 'Step ?').trim() || 'Step ?';
771
+ if (!mentionedButNotLinkedByStep.has(normalizedStepRef)) {
772
+ mentionedButNotLinkedByStep.set(normalizedStepRef, new Set<string>());
773
+ }
774
+ mentionedButNotLinkedByStep.get(normalizedStepRef)!.add(normalizedRequirementId);
775
+ };
776
+
777
+ const sortedMissingMentioned = [...new Set(missingMentioned)].sort((a, b) => a.localeCompare(b));
778
+ const sortedMissingFamily = [...new Set(missingFamily)].sort((a, b) => a.localeCompare(b));
779
+ for (const code of sortedMissingMentioned) {
780
+ const stepRef = mentionedCodeFirstStep.get(code) || 'Step ?';
781
+ appendMentionedButNotLinked(code, stepRef);
782
+ }
783
+ for (const code of sortedMissingFamily) {
784
+ const baseKey = this.toRequirementKey(code);
785
+ const stepRef = mentionedBaseFirstStep.get(baseKey) || 'Step ?';
786
+ appendMentionedButNotLinked(code, stepRef);
787
+ }
788
+
789
+ const sortedExtraLinked = [...new Set(extraLinked)]
790
+ .map((code) => this.normalizeMewpRequirementCodeWithSuffix(code))
791
+ .filter((code) => !!code)
792
+ .sort((a, b) => a.localeCompare(b));
793
+
794
+ const parseStepOrder = (stepRef: string): number => {
795
+ const match = /step\s+(\d+)/i.exec(String(stepRef || ''));
796
+ const parsed = Number(match?.[1] || Number.POSITIVE_INFINITY);
797
+ return Number.isFinite(parsed) ? parsed : Number.POSITIVE_INFINITY;
798
+ };
799
+ const mentionedButNotLinked = [...mentionedButNotLinkedByStep.entries()]
800
+ .sort((a, b) => {
801
+ const stepOrderA = parseStepOrder(a[0]);
802
+ const stepOrderB = parseStepOrder(b[0]);
803
+ if (stepOrderA !== stepOrderB) return stepOrderA - stepOrderB;
804
+ return String(a[0]).localeCompare(String(b[0]));
805
+ })
806
+ .map(([stepRef, requirementIds]) => {
807
+ const requirementList = [...requirementIds].sort((a, b) => a.localeCompare(b));
808
+ return `${stepRef}: ${requirementList.join(', ')}`;
809
+ })
810
+ .join('; ');
811
+ const linkedButNotMentioned = sortedExtraLinked.join('; ');
812
+ const validationStatus: 'Pass' | 'Fail' =
813
+ mentionedButNotLinked || linkedButNotMentioned ? 'Fail' : 'Pass';
814
+
815
+ rows.push({
816
+ 'Test Case ID': testCaseId,
817
+ 'Test Case Title': String(testCaseTitleMap.get(testCaseId) || '').trim(),
818
+ 'Mentioned but Not Linked': mentionedButNotLinked,
819
+ 'Linked but Not Mentioned': linkedButNotMentioned,
820
+ 'Validation Status': validationStatus,
821
+ });
822
+ }
823
+
824
+ return {
825
+ sheetName: this.buildInternalValidationSheetName(planName, testPlanId),
826
+ columnOrder: [...ResultDataProvider.INTERNAL_VALIDATION_COLUMNS],
827
+ rows,
828
+ };
829
+ } catch (error: any) {
830
+ logger.error(`Error during getMewpInternalValidationFlatResults: ${error.message}`);
505
831
  return defaultPayload;
506
832
  }
507
833
  }
508
834
 
835
+ public async validateMewpExternalFiles(options: {
836
+ externalBugsFile?: MewpExternalFileRef | null;
837
+ externalL3L4File?: MewpExternalFileRef | null;
838
+ }): Promise<MewpExternalFilesValidationResponse> {
839
+ const response: MewpExternalFilesValidationResponse = { valid: true };
840
+ const validateOne = async (
841
+ file: MewpExternalFileRef | null | undefined,
842
+ tableType: 'bugs' | 'l3l4'
843
+ ): Promise<MewpExternalTableValidationResult | undefined> => {
844
+ const sourceName = String(file?.name || file?.objectName || file?.text || file?.url || '').trim();
845
+ if (!sourceName) return undefined;
846
+
847
+ try {
848
+ const { rows, meta } = await this.mewpExternalTableUtils.loadExternalTableRowsWithMeta(
849
+ file,
850
+ tableType
851
+ );
852
+ return {
853
+ tableType,
854
+ sourceName: meta.sourceName,
855
+ valid: true,
856
+ headerRow: meta.headerRow,
857
+ matchedRequiredColumns: meta.matchedRequiredColumns,
858
+ totalRequiredColumns: meta.totalRequiredColumns,
859
+ missingRequiredColumns: [],
860
+ rowCount: rows.length,
861
+ message: 'File schema is valid',
862
+ };
863
+ } catch (error: any) {
864
+ if (error instanceof MewpExternalFileValidationError) {
865
+ return {
866
+ ...error.details,
867
+ valid: false,
868
+ };
869
+ }
870
+ return {
871
+ tableType,
872
+ sourceName: sourceName || tableType,
873
+ valid: false,
874
+ headerRow: '',
875
+ matchedRequiredColumns: 0,
876
+ totalRequiredColumns: this.mewpExternalTableUtils.getRequiredColumnCount(tableType),
877
+ missingRequiredColumns: [],
878
+ rowCount: 0,
879
+ message: String(error?.message || error || 'Unknown validation error'),
880
+ };
881
+ }
882
+ };
883
+
884
+ response.bugs = await validateOne(options?.externalBugsFile, 'bugs');
885
+ response.l3l4 = await validateOne(options?.externalL3L4File, 'l3l4');
886
+ response.valid = [response.bugs, response.l3l4].filter(Boolean).every((item) => !!item?.valid);
887
+ return response;
888
+ }
889
+
509
890
  /**
510
891
  * Mapping each attachment to a proper URL for downloading it
511
892
  * @param runResults Array of run results
@@ -549,33 +930,63 @@ export default class ResultDataProvider {
549
930
  return `MEWP L2 Coverage - ${suffix}`;
550
931
  }
551
932
 
933
+ private buildInternalValidationSheetName(planName: string, testPlanId: string): string {
934
+ const suffix = String(planName || '').trim() || `Plan ${testPlanId}`;
935
+ return `MEWP Internal Validation - ${suffix}`;
936
+ }
937
+
552
938
  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) : '';
939
+ requirement: Pick<MewpL2RequirementFamily, 'requirementId' | 'title' | 'subSystem' | 'responsibility'>,
940
+ runStatus: MewpRunStatus,
941
+ bug: MewpCoverageBugCell,
942
+ linkedL3L4: MewpCoverageL3L4Cell
943
+ ): MewpCoverageRow {
944
+ const l2ReqId = this.formatMewpCustomerId(requirement.requirementId);
945
+ const l2ReqTitle = this.toMewpComparableText(requirement.title);
946
+ const l2SubSystem = this.toMewpComparableText(requirement.subSystem);
566
947
 
567
948
  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,
949
+ 'L2 REQ ID': l2ReqId,
950
+ 'L2 REQ Title': l2ReqTitle,
951
+ 'L2 SubSystem': l2SubSystem,
952
+ 'L2 Run Status': runStatus,
953
+ 'Bug ID': Number.isFinite(Number(bug?.id)) && Number(bug?.id) > 0 ? Number(bug?.id) : '',
954
+ 'Bug Title': String(bug?.title || '').trim(),
955
+ 'Bug Responsibility': String(bug?.responsibility || '').trim(),
956
+ 'L3 REQ ID': String(linkedL3L4?.l3Id || '').trim(),
957
+ 'L3 REQ Title': String(linkedL3L4?.l3Title || '').trim(),
958
+ 'L4 REQ ID': String(linkedL3L4?.l4Id || '').trim(),
959
+ 'L4 REQ Title': String(linkedL3L4?.l4Title || '').trim(),
576
960
  };
577
961
  }
578
962
 
963
+ private createEmptyMewpCoverageBugCell(): MewpCoverageBugCell {
964
+ return { id: '' as '', title: '', responsibility: '' };
965
+ }
966
+
967
+ private createEmptyMewpCoverageL3L4Cell(): MewpCoverageL3L4Cell {
968
+ return { l3Id: '', l3Title: '', l4Id: '', l4Title: '' };
969
+ }
970
+
971
+ private buildMewpCoverageL3L4Rows(links: MewpL3L4Link[]): MewpCoverageL3L4Cell[] {
972
+ const sorted = [...(links || [])].sort((a, b) => {
973
+ if (a.level !== b.level) return a.level === 'L3' ? -1 : 1;
974
+ return String(a.id || '').localeCompare(String(b.id || ''));
975
+ });
976
+
977
+ const rows: MewpCoverageL3L4Cell[] = [];
978
+ for (const item of sorted) {
979
+ const isL3 = item.level === 'L3';
980
+ rows.push({
981
+ l3Id: isL3 ? String(item?.id || '').trim() : '',
982
+ l3Title: isL3 ? String(item?.title || '').trim() : '',
983
+ l4Id: isL3 ? '' : String(item?.id || '').trim(),
984
+ l4Title: isL3 ? '' : String(item?.title || '').trim(),
985
+ });
986
+ }
987
+ return rows;
988
+ }
989
+
579
990
  private formatMewpCustomerId(rawValue: string): string {
580
991
  const normalized = this.normalizeMewpRequirementCode(this.toMewpComparableText(rawValue));
581
992
  if (normalized) return normalized;
@@ -586,51 +997,109 @@ export default class ResultDataProvider {
586
997
  }
587
998
 
588
999
  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 }>>,
1000
+ requirements: MewpL2RequirementFamily[],
1001
+ requirementIndex: MewpRequirementIndex,
596
1002
  observedTestCaseIdsByRequirement: Map<string, Set<number>>,
597
- testCaseTitleMap: Map<number, string>
598
- ): any[] {
599
- const rows: any[] = [];
1003
+ linkedRequirementsByTestCase: MewpLinkedRequirementsByTestCase,
1004
+ l3l4ByBaseKey: Map<string, MewpL3L4Link[]>,
1005
+ externalBugsByTestCase: Map<number, MewpBugLink[]>
1006
+ ): MewpCoverageRow[] {
1007
+ const rows: MewpCoverageRow[] = [];
1008
+ const linkedByRequirement = this.invertBaseRequirementLinks(linkedRequirementsByTestCase);
600
1009
  for (const requirement of requirements) {
601
- const key = this.toRequirementKey(requirement.requirementId);
1010
+ const key = String(requirement?.baseKey || this.toRequirementKey(requirement.requirementId) || '').trim();
602
1011
  const linkedTestCaseIds = (requirement?.linkedTestCaseIds || []).filter(
603
1012
  (id) => Number.isFinite(id) && Number(id) > 0
604
1013
  );
1014
+ const linkedByTestCase = key ? Array.from(linkedByRequirement.get(key) || []) : [];
605
1015
  const observedTestCaseIds = key
606
1016
  ? Array.from(observedTestCaseIdsByRequirement.get(key) || [])
607
1017
  : [];
608
1018
 
609
- const testCaseIds = Array.from(new Set<number>([...linkedTestCaseIds, ...observedTestCaseIds])).sort(
610
- (a, b) => a - b
611
- );
1019
+ const testCaseIds = Array.from(
1020
+ new Set<number>([...linkedTestCaseIds, ...linkedByTestCase, ...observedTestCaseIds])
1021
+ ).sort((a, b) => a - b);
612
1022
 
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
- }
1023
+ let totalPassed = 0;
1024
+ let totalFailed = 0;
1025
+ let totalNotRun = 0;
1026
+ const aggregatedBugs = new Map<number, MewpBugLink>();
623
1027
 
624
1028
  for (const testCaseId of testCaseIds) {
625
1029
  const summary = key
626
1030
  ? requirementIndex.get(key)?.get(testCaseId) || { passed: 0, failed: 0, notRun: 0 }
627
1031
  : { passed: 0, failed: 0, notRun: 0 };
1032
+ totalPassed += summary.passed;
1033
+ totalFailed += summary.failed;
1034
+ totalNotRun += summary.notRun;
1035
+
1036
+ if (summary.failed > 0) {
1037
+ const externalBugs = externalBugsByTestCase.get(testCaseId) || [];
1038
+ for (const bug of externalBugs) {
1039
+ const bugBaseKey = String(bug?.requirementBaseKey || '').trim();
1040
+ if (bugBaseKey && bugBaseKey !== key) continue;
1041
+ const bugId = Number(bug?.id || 0);
1042
+ if (!Number.isFinite(bugId) || bugId <= 0) continue;
1043
+ aggregatedBugs.set(bugId, {
1044
+ ...bug,
1045
+ responsibility: this.resolveCoverageBugResponsibility(
1046
+ String(bug?.responsibility || ''),
1047
+ requirement
1048
+ ),
1049
+ });
1050
+ }
1051
+ }
1052
+ }
1053
+
1054
+ const runStatus = this.resolveMewpL2RunStatus({
1055
+ passed: totalPassed,
1056
+ failed: totalFailed,
1057
+ notRun: totalNotRun,
1058
+ hasAnyTestCase: testCaseIds.length > 0,
1059
+ });
1060
+
1061
+ const bugsForRows =
1062
+ runStatus === 'Fail'
1063
+ ? Array.from(aggregatedBugs.values()).sort((a, b) => a.id - b.id)
1064
+ : [];
1065
+ const l3l4ForRows = [...(l3l4ByBaseKey.get(key) || [])];
1066
+
1067
+ const bugRows: MewpCoverageBugCell[] =
1068
+ bugsForRows.length > 0
1069
+ ? bugsForRows
1070
+ : [];
1071
+ const l3l4Rows: MewpCoverageL3L4Cell[] = this.buildMewpCoverageL3L4Rows(l3l4ForRows);
1072
+
1073
+ if (bugRows.length === 0 && l3l4Rows.length === 0) {
628
1074
  rows.push(
629
1075
  this.createMewpCoverageRow(
630
1076
  requirement,
631
- testCaseId,
632
- String(testCaseTitleMap.get(testCaseId) || ''),
633
- summary
1077
+ runStatus,
1078
+ this.createEmptyMewpCoverageBugCell(),
1079
+ this.createEmptyMewpCoverageL3L4Cell()
1080
+ )
1081
+ );
1082
+ continue;
1083
+ }
1084
+
1085
+ for (const bug of bugRows) {
1086
+ rows.push(
1087
+ this.createMewpCoverageRow(
1088
+ requirement,
1089
+ runStatus,
1090
+ bug,
1091
+ this.createEmptyMewpCoverageL3L4Cell()
1092
+ )
1093
+ );
1094
+ }
1095
+
1096
+ for (const linkedL3L4 of l3l4Rows) {
1097
+ rows.push(
1098
+ this.createMewpCoverageRow(
1099
+ requirement,
1100
+ runStatus,
1101
+ this.createEmptyMewpCoverageBugCell(),
1102
+ linkedL3L4
634
1103
  )
635
1104
  );
636
1105
  }
@@ -639,6 +1108,262 @@ export default class ResultDataProvider {
639
1108
  return rows;
640
1109
  }
641
1110
 
1111
+ private resolveCoverageBugResponsibility(
1112
+ rawResponsibility: string,
1113
+ requirement: Pick<MewpL2RequirementFamily, 'responsibility'>
1114
+ ): string {
1115
+ const direct = String(rawResponsibility || '').trim();
1116
+ if (direct && direct.toLowerCase() !== 'unknown') return direct;
1117
+
1118
+ const requirementResponsibility = String(requirement?.responsibility || '')
1119
+ .trim()
1120
+ .toUpperCase();
1121
+ if (requirementResponsibility === 'ESUK') return 'ESUK';
1122
+ if (requirementResponsibility === 'IL' || requirementResponsibility === 'ELISRA') return 'Elisra';
1123
+
1124
+ return direct || 'Unknown';
1125
+ }
1126
+
1127
+ private resolveMewpL2RunStatus(input: {
1128
+ passed: number;
1129
+ failed: number;
1130
+ notRun: number;
1131
+ hasAnyTestCase: boolean;
1132
+ }): MewpRunStatus {
1133
+ if ((input?.failed || 0) > 0) return 'Fail';
1134
+ if ((input?.notRun || 0) > 0) return 'Not Run';
1135
+ if ((input?.passed || 0) > 0) return 'Pass';
1136
+ return input?.hasAnyTestCase ? 'Not Run' : 'Not Run';
1137
+ }
1138
+
1139
+ private async fetchMewpScopedTestData(
1140
+ testPlanId: string,
1141
+ projectName: string,
1142
+ selectedSuiteIds: number[] | undefined,
1143
+ useRelFallback: boolean
1144
+ ): Promise<any[]> {
1145
+ if (!useRelFallback) {
1146
+ const suites = await this.fetchTestSuites(testPlanId, projectName, selectedSuiteIds, true);
1147
+ return this.fetchTestData(suites, projectName, testPlanId, false);
1148
+ }
1149
+
1150
+ const selectedSuites = await this.fetchTestSuites(testPlanId, projectName, selectedSuiteIds, true);
1151
+ const selectedRel = this.resolveMaxRelNumberFromSuites(selectedSuites);
1152
+ if (selectedRel <= 0) {
1153
+ return this.fetchTestData(selectedSuites, projectName, testPlanId, false);
1154
+ }
1155
+
1156
+ const allSuites = await this.fetchTestSuites(testPlanId, projectName, undefined, true);
1157
+ const relScopedSuites = allSuites.filter((suite) => {
1158
+ const rel = this.extractRelNumberFromSuite(suite);
1159
+ return rel > 0 && rel <= selectedRel;
1160
+ });
1161
+ const suitesForFetch = relScopedSuites.length > 0 ? relScopedSuites : selectedSuites;
1162
+ const rawTestData = await this.fetchTestData(suitesForFetch, projectName, testPlanId, false);
1163
+ return this.reduceToLatestRelRunPerTestCase(rawTestData);
1164
+ }
1165
+
1166
+ private extractRelNumberFromSuite(suite: any): number {
1167
+ const candidates = [
1168
+ suite?.suiteName,
1169
+ suite?.parentSuiteName,
1170
+ suite?.suitePath,
1171
+ suite?.testGroupName,
1172
+ ];
1173
+ const pattern = /(?:^|[^a-z0-9])rel\s*([0-9]+)/i;
1174
+ for (const item of candidates) {
1175
+ const match = pattern.exec(String(item || ''));
1176
+ if (!match) continue;
1177
+ const parsed = Number(match[1]);
1178
+ if (Number.isFinite(parsed) && parsed > 0) {
1179
+ return parsed;
1180
+ }
1181
+ }
1182
+ return 0;
1183
+ }
1184
+
1185
+ private resolveMaxRelNumberFromSuites(suites: any[]): number {
1186
+ let maxRel = 0;
1187
+ for (const suite of suites || []) {
1188
+ const rel = this.extractRelNumberFromSuite(suite);
1189
+ if (rel > maxRel) maxRel = rel;
1190
+ }
1191
+ return maxRel;
1192
+ }
1193
+
1194
+ private reduceToLatestRelRunPerTestCase(testData: any[]): any[] {
1195
+ type Candidate = {
1196
+ point: any;
1197
+ rel: number;
1198
+ runId: number;
1199
+ resultId: number;
1200
+ hasRun: boolean;
1201
+ };
1202
+
1203
+ const candidatesByTestCase = new Map<number, Candidate[]>();
1204
+ const testCaseDefinitionById = new Map<number, any>();
1205
+
1206
+ for (const suite of testData || []) {
1207
+ const rel = this.extractRelNumberFromSuite(suite);
1208
+ const testPointsItems = Array.isArray(suite?.testPointsItems) ? suite.testPointsItems : [];
1209
+ const testCasesItems = Array.isArray(suite?.testCasesItems) ? suite.testCasesItems : [];
1210
+
1211
+ for (const testCase of testCasesItems) {
1212
+ const testCaseId = Number(testCase?.workItem?.id || testCase?.testCaseId || testCase?.id || 0);
1213
+ if (!Number.isFinite(testCaseId) || testCaseId <= 0) continue;
1214
+ if (!testCaseDefinitionById.has(testCaseId)) {
1215
+ testCaseDefinitionById.set(testCaseId, testCase);
1216
+ }
1217
+ }
1218
+
1219
+ for (const point of testPointsItems) {
1220
+ const testCaseId = Number(point?.testCaseId || point?.testCase?.id || 0);
1221
+ if (!Number.isFinite(testCaseId) || testCaseId <= 0) continue;
1222
+
1223
+ const runId = Number(point?.lastRunId || 0);
1224
+ const resultId = Number(point?.lastResultId || 0);
1225
+ const hasRun = runId > 0 && resultId > 0;
1226
+ if (!candidatesByTestCase.has(testCaseId)) {
1227
+ candidatesByTestCase.set(testCaseId, []);
1228
+ }
1229
+ candidatesByTestCase.get(testCaseId)!.push({
1230
+ point,
1231
+ rel,
1232
+ runId,
1233
+ resultId,
1234
+ hasRun,
1235
+ });
1236
+ }
1237
+ }
1238
+
1239
+ const selectedPoints: any[] = [];
1240
+ const selectedTestCaseIds = new Set<number>();
1241
+ for (const [testCaseId, candidates] of candidatesByTestCase.entries()) {
1242
+ const sorted = [...candidates].sort((a, b) => {
1243
+ if (a.hasRun !== b.hasRun) return a.hasRun ? -1 : 1;
1244
+ if (a.rel !== b.rel) return b.rel - a.rel;
1245
+ if (a.runId !== b.runId) return b.runId - a.runId;
1246
+ return b.resultId - a.resultId;
1247
+ });
1248
+ const chosen = sorted[0];
1249
+ if (!chosen?.point) continue;
1250
+ selectedPoints.push(chosen.point);
1251
+ selectedTestCaseIds.add(testCaseId);
1252
+ }
1253
+
1254
+ const selectedTestCases: any[] = [];
1255
+ for (const testCaseId of selectedTestCaseIds) {
1256
+ const definition = testCaseDefinitionById.get(testCaseId);
1257
+ if (definition) {
1258
+ selectedTestCases.push(definition);
1259
+ }
1260
+ }
1261
+
1262
+ return [
1263
+ {
1264
+ testSuiteId: 'MEWP_REL_SCOPED',
1265
+ suiteId: 'MEWP_REL_SCOPED',
1266
+ suiteName: 'MEWP Rel Scoped',
1267
+ parentSuiteId: '',
1268
+ parentSuiteName: '',
1269
+ suitePath: 'MEWP Rel Scoped',
1270
+ testGroupName: 'MEWP Rel Scoped',
1271
+ testPointsItems: selectedPoints,
1272
+ testCasesItems: selectedTestCases,
1273
+ },
1274
+ ];
1275
+ }
1276
+
1277
+ private async loadExternalBugsByTestCase(
1278
+ externalBugsFile: MewpExternalFileRef | null | undefined
1279
+ ): Promise<Map<number, MewpBugLink[]>> {
1280
+ return this.mewpExternalIngestionUtils.loadExternalBugsByTestCase(externalBugsFile, {
1281
+ toComparableText: (value) => this.toMewpComparableText(value),
1282
+ toRequirementKey: (value) => this.toRequirementKey(value),
1283
+ resolveBugResponsibility: (fields) => this.resolveBugResponsibility(fields),
1284
+ isExternalStateInScope: (value, itemType) => this.isExternalStateInScope(value, itemType),
1285
+ isExcludedL3L4BySapWbs: (value) => this.isExcludedL3L4BySapWbs(value),
1286
+ resolveRequirementSapWbsByBaseKey: () => '',
1287
+ });
1288
+ }
1289
+
1290
+ private async loadExternalL3L4ByBaseKey(
1291
+ externalL3L4File: MewpExternalFileRef | null | undefined,
1292
+ requirementSapWbsByBaseKey: Map<string, string> = new Map<string, string>()
1293
+ ): Promise<Map<string, MewpL3L4Link[]>> {
1294
+ return this.mewpExternalIngestionUtils.loadExternalL3L4ByBaseKey(externalL3L4File, {
1295
+ toComparableText: (value) => this.toMewpComparableText(value),
1296
+ toRequirementKey: (value) => this.toRequirementKey(value),
1297
+ resolveBugResponsibility: (fields) => this.resolveBugResponsibility(fields),
1298
+ isExternalStateInScope: (value, itemType) => this.isExternalStateInScope(value, itemType),
1299
+ isExcludedL3L4BySapWbs: (value) => this.isExcludedL3L4BySapWbs(value),
1300
+ resolveRequirementSapWbsByBaseKey: (baseKey) => String(requirementSapWbsByBaseKey.get(baseKey) || ''),
1301
+ });
1302
+ }
1303
+
1304
+ private buildRequirementSapWbsByBaseKey(
1305
+ requirements: Array<Pick<MewpL2RequirementWorkItem, 'baseKey' | 'responsibility'>>
1306
+ ): Map<string, string> {
1307
+ const out = new Map<string, string>();
1308
+ for (const requirement of requirements || []) {
1309
+ const baseKey = String(requirement?.baseKey || '').trim();
1310
+ if (!baseKey) continue;
1311
+
1312
+ const normalized = this.resolveMewpResponsibility(this.toMewpComparableText(requirement?.responsibility));
1313
+ if (!normalized) continue;
1314
+
1315
+ const existing = out.get(baseKey) || '';
1316
+ // Keep ESUK as dominant if conflicting values are ever present across family items.
1317
+ if (existing === 'ESUK') continue;
1318
+ if (normalized === 'ESUK' || !existing) {
1319
+ out.set(baseKey, normalized);
1320
+ }
1321
+ }
1322
+ return out;
1323
+ }
1324
+
1325
+ private isExternalStateInScope(value: string, itemType: 'bug' | 'requirement'): boolean {
1326
+ const normalized = String(value || '').trim().toLowerCase();
1327
+ if (!normalized) return true;
1328
+
1329
+ // TFS/ADO processes usually don't expose a literal "Open" state.
1330
+ // Keep non-terminal states, exclude terminal states.
1331
+ const terminalStates = new Set<string>([
1332
+ 'resolved',
1333
+ 'closed',
1334
+ 'done',
1335
+ 'completed',
1336
+ 'complete',
1337
+ 'removed',
1338
+ 'rejected',
1339
+ 'cancelled',
1340
+ 'canceled',
1341
+ 'obsolete',
1342
+ ]);
1343
+
1344
+ if (terminalStates.has(normalized)) return false;
1345
+
1346
+ // Bug-specific terminal variants often used in custom processes.
1347
+ if (itemType === 'bug') {
1348
+ if (normalized === 'fixed') return false;
1349
+ }
1350
+
1351
+ return true;
1352
+ }
1353
+
1354
+ private invertBaseRequirementLinks(
1355
+ linkedRequirementsByTestCase: MewpLinkedRequirementsByTestCase
1356
+ ): Map<string, Set<number>> {
1357
+ const out = new Map<string, Set<number>>();
1358
+ for (const [testCaseId, links] of linkedRequirementsByTestCase.entries()) {
1359
+ for (const baseKey of links?.baseKeys || []) {
1360
+ if (!out.has(baseKey)) out.set(baseKey, new Set<number>());
1361
+ out.get(baseKey)!.add(testCaseId);
1362
+ }
1363
+ }
1364
+ return out;
1365
+ }
1366
+
642
1367
  private buildMewpTestCaseTitleMap(testData: any[]): Map<number, string> {
643
1368
  const map = new Map<number, string>();
644
1369
 
@@ -736,56 +1461,197 @@ export default class ResultDataProvider {
736
1461
  return 'notRun';
737
1462
  }
738
1463
 
739
- private accumulateRequirementCountsFromStepText(
740
- stepText: string,
741
- status: 'passed' | 'failed' | 'notRun',
1464
+ private accumulateRequirementCountsFromActionResults(
1465
+ actionResults: any[],
742
1466
  testCaseId: number,
743
1467
  requirementKeys: Set<string>,
744
1468
  counters: Map<string, Map<number, { passed: number; failed: number; notRun: number }>>,
745
1469
  observedTestCaseIdsByRequirement: Map<string, Set<number>>
746
1470
  ) {
747
1471
  if (!Number.isFinite(testCaseId) || testCaseId <= 0) return;
1472
+ const sortedResults = Array.isArray(actionResults) ? actionResults : [];
1473
+ let previousRequirementStepIndex = -1;
1474
+
1475
+ for (let i = 0; i < sortedResults.length; i++) {
1476
+ const actionResult = sortedResults[i];
1477
+ if (actionResult?.isSharedStepTitle) continue;
1478
+ const requirementCodes = this.extractRequirementCodesFromText(actionResult?.expected || '');
1479
+ if (requirementCodes.size === 0) continue;
1480
+
1481
+ const startIndex = previousRequirementStepIndex + 1;
1482
+ const status = this.resolveRequirementStatusForWindow(sortedResults, startIndex, i);
1483
+ previousRequirementStepIndex = i;
1484
+
1485
+ for (const code of requirementCodes) {
1486
+ if (requirementKeys.size > 0 && !requirementKeys.has(code)) continue;
1487
+ if (!counters.has(code)) {
1488
+ counters.set(code, new Map<number, { passed: number; failed: number; notRun: number }>());
1489
+ }
1490
+ const perTestCaseCounters = counters.get(code)!;
1491
+ if (!perTestCaseCounters.has(testCaseId)) {
1492
+ perTestCaseCounters.set(testCaseId, { passed: 0, failed: 0, notRun: 0 });
1493
+ }
748
1494
 
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
- }
1495
+ if (!observedTestCaseIdsByRequirement.has(code)) {
1496
+ observedTestCaseIdsByRequirement.set(code, new Set<number>());
1497
+ }
1498
+ observedTestCaseIdsByRequirement.get(code)!.add(testCaseId);
759
1499
 
760
- if (!observedTestCaseIdsByRequirement.has(code)) {
761
- observedTestCaseIdsByRequirement.set(code, new Set<number>());
1500
+ const counter = perTestCaseCounters.get(testCaseId)!;
1501
+ if (status === 'passed') counter.passed += 1;
1502
+ else if (status === 'failed') counter.failed += 1;
1503
+ else counter.notRun += 1;
762
1504
  }
763
- observedTestCaseIdsByRequirement.get(code)!.add(testCaseId);
1505
+ }
1506
+ }
764
1507
 
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;
1508
+ private resolveRequirementStatusForWindow(
1509
+ actionResults: any[],
1510
+ startIndex: number,
1511
+ endIndex: number
1512
+ ): 'passed' | 'failed' | 'notRun' {
1513
+ let hasNotRun = false;
1514
+ for (let index = startIndex; index <= endIndex; index++) {
1515
+ const status = this.classifyRequirementStepOutcome(actionResults[index]?.outcome);
1516
+ if (status === 'failed') return 'failed';
1517
+ if (status === 'notRun') hasNotRun = true;
769
1518
  }
1519
+ return hasNotRun ? 'notRun' : 'passed';
770
1520
  }
771
1521
 
772
1522
  private extractRequirementCodesFromText(text: string): Set<string> {
1523
+ return this.extractRequirementCodesFromExpectedText(text, false);
1524
+ }
1525
+
1526
+ private extractRequirementMentionsFromExpectedSteps(
1527
+ steps: TestSteps[],
1528
+ includeSuffix: boolean
1529
+ ): Array<{ stepRef: string; codes: Set<string> }> {
1530
+ const out: Array<{ stepRef: string; codes: Set<string> }> = [];
1531
+ const allSteps = Array.isArray(steps) ? steps : [];
1532
+ for (let index = 0; index < allSteps.length; index += 1) {
1533
+ const step = allSteps[index];
1534
+ if (step?.isSharedStepTitle) continue;
1535
+ const codes = this.extractRequirementCodesFromExpectedText(step?.expected || '', includeSuffix);
1536
+ if (codes.size === 0) continue;
1537
+ out.push({
1538
+ stepRef: this.resolveValidationStepReference(step, index),
1539
+ codes,
1540
+ });
1541
+ }
1542
+ return out;
1543
+ }
1544
+
1545
+ private extractRequirementCodesFromExpectedSteps(steps: TestSteps[], includeSuffix: boolean): Set<string> {
1546
+ const out = new Set<string>();
1547
+ for (const step of Array.isArray(steps) ? steps : []) {
1548
+ if (step?.isSharedStepTitle) continue;
1549
+ const codes = this.extractRequirementCodesFromExpectedText(step?.expected || '', includeSuffix);
1550
+ codes.forEach((code) => out.add(code));
1551
+ }
1552
+ return out;
1553
+ }
1554
+
1555
+ private extractRequirementCodesFromExpectedText(text: string, includeSuffix: boolean): Set<string> {
773
1556
  const out = new Set<string>();
774
1557
  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}`);
1558
+ if (!source) return out;
1559
+
1560
+ const tokens = source
1561
+ .split(';')
1562
+ .map((token) => String(token || '').trim())
1563
+ .filter((token) => token !== '');
1564
+
1565
+ for (const token of tokens) {
1566
+ const candidates = this.extractRequirementCandidatesFromToken(token);
1567
+ for (const candidate of candidates) {
1568
+ const expandedTokens = this.expandRequirementTokenByComma(candidate);
1569
+ for (const expandedToken of expandedTokens) {
1570
+ if (!expandedToken || /vvrm/i.test(expandedToken)) continue;
1571
+ const normalized = this.normalizeRequirementCodeToken(expandedToken, includeSuffix);
1572
+ if (normalized) {
1573
+ out.add(normalized);
1574
+ }
1575
+ }
784
1576
  }
785
1577
  }
1578
+
786
1579
  return out;
787
1580
  }
788
1581
 
1582
+ private extractRequirementCandidatesFromToken(token: string): string[] {
1583
+ const source = String(token || '');
1584
+ if (!source) return [];
1585
+ const out = new Set<string>();
1586
+ const collectCandidates = (input: string, rejectTailPattern: RegExp) => {
1587
+ for (const match of input.matchAll(/SR\d{4,}(?:-\d+(?:,\d+)*)?/gi)) {
1588
+ const matchedValue = String(match?.[0] || '')
1589
+ .trim()
1590
+ .toUpperCase();
1591
+ if (!matchedValue) continue;
1592
+ const endIndex = Number(match?.index || 0) + matchedValue.length;
1593
+ const tail = String(input.slice(endIndex) || '');
1594
+ if (rejectTailPattern.test(tail)) continue;
1595
+ out.add(matchedValue);
1596
+ }
1597
+ };
1598
+
1599
+ // Normal scan keeps punctuation context (" SR0817-V3.2 " -> reject via tail).
1600
+ collectCandidates(source, /^\s*(?:V\d|VVRM|-V\d)/i);
1601
+
1602
+ // Compact scan preserves legacy support for spaced SR letters/digits
1603
+ // such as "S R 0 0 0 1" and HTML-fragmented tokens.
1604
+ const compactSource = source.replace(/\s+/g, '');
1605
+ if (compactSource && compactSource !== source) {
1606
+ collectCandidates(compactSource, /^(?:V\d|VVRM|-V\d)/i);
1607
+ }
1608
+
1609
+ return [...out];
1610
+ }
1611
+
1612
+ private expandRequirementTokenByComma(token: string): string[] {
1613
+ const compact = String(token || '').trim().toUpperCase();
1614
+ if (!compact) return [];
1615
+
1616
+ const suffixBatchMatch = /^SR(\d{4,})-(\d+(?:,\d+)+)$/.exec(compact);
1617
+ if (suffixBatchMatch) {
1618
+ const base = String(suffixBatchMatch[1] || '').trim();
1619
+ const suffixes = String(suffixBatchMatch[2] || '')
1620
+ .split(',')
1621
+ .map((item) => String(item || '').trim())
1622
+ .filter((item) => /^\d+$/.test(item));
1623
+ return suffixes.map((suffix) => `SR${base}-${suffix}`);
1624
+ }
1625
+
1626
+ return compact
1627
+ .split(',')
1628
+ .map((part) => String(part || '').trim())
1629
+ .filter((part) => !!part);
1630
+ }
1631
+
1632
+ private normalizeRequirementCodeToken(token: string, includeSuffix: boolean): string {
1633
+ const compact = String(token || '')
1634
+ .trim()
1635
+ .replace(/[\u200B-\u200D\uFEFF]/g, '')
1636
+ .toUpperCase();
1637
+ if (!compact) return '';
1638
+
1639
+ const pattern = includeSuffix ? /^SR(\d{4,})(?:-(\d+))?$/ : /^SR(\d{4,})(?:-\d+)?$/;
1640
+ const match = pattern.exec(compact);
1641
+ if (!match) return '';
1642
+
1643
+ const baseDigits = String(match[1] || '').trim();
1644
+ if (!baseDigits) return '';
1645
+
1646
+ if (includeSuffix && match[2]) {
1647
+ const suffixDigits = String(match[2] || '').trim();
1648
+ if (!suffixDigits) return '';
1649
+ return `SR${baseDigits}-${suffixDigits}`;
1650
+ }
1651
+
1652
+ return `SR${baseDigits}`;
1653
+ }
1654
+
789
1655
  private normalizeRequirementStepText(text: string): string {
790
1656
  const raw = String(text || '');
791
1657
  if (!raw) return '';
@@ -802,23 +1668,19 @@ export default class ResultDataProvider {
802
1668
  .replace(/\s+/g, ' ');
803
1669
  }
804
1670
 
1671
+ private resolveValidationStepReference(step: TestSteps, index: number): string {
1672
+ const fromPosition = String(step?.stepPosition || '').trim();
1673
+ if (fromPosition) return `Step ${fromPosition}`;
1674
+ const fromId = String(step?.stepId || '').trim();
1675
+ if (fromId) return `Step ${fromId}`;
1676
+ return `Step ${index + 1}`;
1677
+ }
1678
+
805
1679
  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
- > {
1680
+ return this.normalizeMewpRequirementCode(requirementId);
1681
+ }
1682
+
1683
+ private async fetchMewpL2Requirements(projectName: string): Promise<MewpL2RequirementWorkItem[]> {
822
1684
  const workItemTypeNames = await this.fetchMewpRequirementTypeNames(projectName);
823
1685
  if (workItemTypeNames.length === 0) {
824
1686
  return [];
@@ -827,17 +1689,36 @@ export default class ResultDataProvider {
827
1689
  const quotedTypeNames = workItemTypeNames
828
1690
  .map((name) => `'${String(name).replace(/'/g, "''")}'`)
829
1691
  .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));
1692
+ const queryRequirementIds = async (l2AreaPath: string | null): Promise<number[]> => {
1693
+ const escapedAreaPath = l2AreaPath ? String(l2AreaPath).replace(/'/g, "''") : '';
1694
+ const areaFilter = escapedAreaPath ? `\n AND [System.AreaPath] UNDER '${escapedAreaPath}'` : '';
1695
+ const wiql = `SELECT [System.Id]
1696
+ FROM WorkItems
1697
+ WHERE [System.TeamProject] = @project
1698
+ AND [System.WorkItemType] IN (${quotedTypeNames})${areaFilter}
1699
+ ORDER BY [System.Id]`;
1700
+ const wiqlUrl = `${this.orgUrl}${projectName}/_apis/wit/wiql?api-version=7.1-preview.2`;
1701
+ const wiqlResponse = await TFSServices.postRequest(wiqlUrl, this.token, 'Post', { query: wiql }, null);
1702
+ const workItemRefs = Array.isArray(wiqlResponse?.data?.workItems) ? wiqlResponse.data.workItems : [];
1703
+ return workItemRefs
1704
+ .map((item: any) => Number(item?.id))
1705
+ .filter((id: number) => Number.isFinite(id));
1706
+ };
1707
+
1708
+ const defaultL2AreaPath = `${String(projectName || '').trim()}\\Customer Requirements\\Level 2`;
1709
+ let requirementIds: number[] = [];
1710
+ try {
1711
+ requirementIds = await queryRequirementIds(defaultL2AreaPath);
1712
+ } catch (error: any) {
1713
+ logger.warn(
1714
+ `Could not apply MEWP L2 WIQL area-path optimization. Falling back to full requirement scope: ${
1715
+ error?.message || error
1716
+ }`
1717
+ );
1718
+ }
1719
+ if (requirementIds.length === 0) {
1720
+ requirementIds = await queryRequirementIds(null);
1721
+ }
841
1722
 
842
1723
  if (requirementIds.length === 0) {
843
1724
  return [];
@@ -846,16 +1727,294 @@ ORDER BY [System.Id]`;
846
1727
  const workItems = await this.fetchWorkItemsByIds(projectName, requirementIds, true);
847
1728
  const requirements = workItems.map((wi: any) => {
848
1729
  const fields = wi?.fields || {};
1730
+ const requirementId = this.extractMewpRequirementIdentifier(fields, Number(wi?.id || 0));
1731
+ const areaPath = this.toMewpComparableText(fields?.['System.AreaPath']);
849
1732
  return {
850
1733
  workItemId: Number(wi?.id || 0),
851
- requirementId: this.extractMewpRequirementIdentifier(fields, Number(wi?.id || 0)),
1734
+ requirementId,
1735
+ baseKey: this.toRequirementKey(requirementId),
852
1736
  title: this.toMewpComparableText(fields?.['System.Title'] || wi?.title),
1737
+ subSystem: this.deriveMewpSubSystem(fields),
853
1738
  responsibility: this.deriveMewpResponsibility(fields),
854
1739
  linkedTestCaseIds: this.extractLinkedTestCaseIdsFromRequirement(wi?.relations || []),
1740
+ relatedWorkItemIds: this.extractLinkedWorkItemIdsFromRelations(wi?.relations || []),
1741
+ areaPath,
855
1742
  };
856
1743
  });
857
1744
 
858
- return requirements.sort((a, b) => String(a.requirementId).localeCompare(String(b.requirementId)));
1745
+ return requirements
1746
+ .filter((item) => {
1747
+ if (!item.baseKey) return false;
1748
+ if (!item.areaPath) return true;
1749
+ return this.isMewpL2AreaPath(item.areaPath);
1750
+ })
1751
+ .sort((a, b) => String(a.requirementId).localeCompare(String(b.requirementId)));
1752
+ }
1753
+
1754
+ private isMewpL2AreaPath(areaPath: string): boolean {
1755
+ const normalized = String(areaPath || '')
1756
+ .trim()
1757
+ .toLowerCase()
1758
+ .replace(/\//g, '\\');
1759
+ if (!normalized) return false;
1760
+ return normalized.includes('\\customer requirements\\level 2');
1761
+ }
1762
+
1763
+ private collapseMewpRequirementFamilies(
1764
+ requirements: MewpL2RequirementWorkItem[],
1765
+ scopedRequirementKeys?: Set<string>
1766
+ ): MewpL2RequirementFamily[] {
1767
+ const families = new Map<
1768
+ string,
1769
+ {
1770
+ representative: MewpL2RequirementWorkItem;
1771
+ score: number;
1772
+ linkedTestCaseIds: Set<number>;
1773
+ }
1774
+ >();
1775
+
1776
+ const calcScore = (item: MewpL2RequirementWorkItem) => {
1777
+ const requirementId = String(item?.requirementId || '').trim();
1778
+ const areaPath = String(item?.areaPath || '')
1779
+ .trim()
1780
+ .toLowerCase();
1781
+ let score = 0;
1782
+ if (/^SR\d+$/i.test(requirementId)) score += 6;
1783
+ if (areaPath.includes('\\customer requirements\\level 2')) score += 3;
1784
+ if (!areaPath.includes('\\mop')) score += 2;
1785
+ if (String(item?.title || '').trim()) score += 1;
1786
+ if (String(item?.subSystem || '').trim()) score += 1;
1787
+ if (String(item?.responsibility || '').trim()) score += 1;
1788
+ return score;
1789
+ };
1790
+
1791
+ for (const requirement of requirements || []) {
1792
+ const baseKey = String(requirement?.baseKey || '').trim();
1793
+ if (!baseKey) continue;
1794
+ if (scopedRequirementKeys?.size && !scopedRequirementKeys.has(baseKey)) continue;
1795
+
1796
+ if (!families.has(baseKey)) {
1797
+ families.set(baseKey, {
1798
+ representative: requirement,
1799
+ score: calcScore(requirement),
1800
+ linkedTestCaseIds: new Set<number>(),
1801
+ });
1802
+ }
1803
+ const family = families.get(baseKey)!;
1804
+ const score = calcScore(requirement);
1805
+ if (score > family.score) {
1806
+ family.representative = requirement;
1807
+ family.score = score;
1808
+ }
1809
+ for (const testCaseId of requirement?.linkedTestCaseIds || []) {
1810
+ if (Number.isFinite(testCaseId) && Number(testCaseId) > 0) {
1811
+ family.linkedTestCaseIds.add(Number(testCaseId));
1812
+ }
1813
+ }
1814
+ }
1815
+
1816
+ return [...families.entries()]
1817
+ .map(([baseKey, family]) => ({
1818
+ requirementId: String(family?.representative?.requirementId || baseKey),
1819
+ baseKey,
1820
+ title: String(family?.representative?.title || ''),
1821
+ subSystem: String(family?.representative?.subSystem || ''),
1822
+ responsibility: String(family?.representative?.responsibility || ''),
1823
+ linkedTestCaseIds: [...family.linkedTestCaseIds].sort((a, b) => a - b),
1824
+ }))
1825
+ .sort((a, b) => String(a.requirementId).localeCompare(String(b.requirementId)));
1826
+ }
1827
+
1828
+ private buildRequirementFamilyMap(
1829
+ requirements: Array<Pick<MewpL2RequirementWorkItem, 'requirementId' | 'baseKey'>>,
1830
+ scopedRequirementKeys?: Set<string>
1831
+ ): Map<string, Set<string>> {
1832
+ const familyMap = new Map<string, Set<string>>();
1833
+ for (const requirement of requirements || []) {
1834
+ const baseKey = String(requirement?.baseKey || '').trim();
1835
+ if (!baseKey) continue;
1836
+ if (scopedRequirementKeys?.size && !scopedRequirementKeys.has(baseKey)) continue;
1837
+ const fullCode = this.normalizeMewpRequirementCodeWithSuffix(requirement?.requirementId || '');
1838
+ if (!fullCode) continue;
1839
+ if (!familyMap.has(baseKey)) familyMap.set(baseKey, new Set<string>());
1840
+ familyMap.get(baseKey)!.add(fullCode);
1841
+ }
1842
+ return familyMap;
1843
+ }
1844
+
1845
+ private async buildLinkedRequirementsByTestCase(
1846
+ requirements: Array<
1847
+ Pick<MewpL2RequirementWorkItem, 'workItemId' | 'requirementId' | 'baseKey' | 'linkedTestCaseIds'>
1848
+ >,
1849
+ testData: any[],
1850
+ projectName: string
1851
+ ): Promise<MewpLinkedRequirementsByTestCase> {
1852
+ const map: MewpLinkedRequirementsByTestCase = new Map();
1853
+ const ensure = (testCaseId: number) => {
1854
+ if (!map.has(testCaseId)) {
1855
+ map.set(testCaseId, {
1856
+ baseKeys: new Set<string>(),
1857
+ fullCodes: new Set<string>(),
1858
+ bugIds: new Set<number>(),
1859
+ });
1860
+ }
1861
+ return map.get(testCaseId)!;
1862
+ };
1863
+
1864
+ const requirementById = new Map<number, { baseKey: string; fullCode: string }>();
1865
+ for (const requirement of requirements || []) {
1866
+ const workItemId = Number(requirement?.workItemId || 0);
1867
+ const baseKey = String(requirement?.baseKey || '').trim();
1868
+ const fullCode = this.normalizeMewpRequirementCodeWithSuffix(requirement?.requirementId || '');
1869
+ if (workItemId > 0 && baseKey && fullCode) {
1870
+ requirementById.set(workItemId, { baseKey, fullCode });
1871
+ }
1872
+
1873
+ for (const testCaseIdRaw of requirement?.linkedTestCaseIds || []) {
1874
+ const testCaseId = Number(testCaseIdRaw);
1875
+ if (!Number.isFinite(testCaseId) || testCaseId <= 0 || !baseKey || !fullCode) continue;
1876
+ const entry = ensure(testCaseId);
1877
+ entry.baseKeys.add(baseKey);
1878
+ entry.fullCodes.add(fullCode);
1879
+ }
1880
+ }
1881
+
1882
+ const testCaseIds = new Set<number>();
1883
+ for (const suite of testData || []) {
1884
+ const testCasesItems = Array.isArray(suite?.testCasesItems) ? suite.testCasesItems : [];
1885
+ for (const testCase of testCasesItems) {
1886
+ const id = Number(testCase?.workItem?.id || testCase?.testCaseId || testCase?.id || 0);
1887
+ if (Number.isFinite(id) && id > 0) testCaseIds.add(id);
1888
+ }
1889
+ const testPointsItems = Array.isArray(suite?.testPointsItems) ? suite.testPointsItems : [];
1890
+ for (const testPoint of testPointsItems) {
1891
+ const id = Number(testPoint?.testCaseId || testPoint?.testCase?.id || 0);
1892
+ if (Number.isFinite(id) && id > 0) testCaseIds.add(id);
1893
+ }
1894
+ }
1895
+
1896
+ const relatedIdsByTestCase = new Map<number, Set<number>>();
1897
+ const allRelatedIds = new Set<number>();
1898
+ if (testCaseIds.size > 0) {
1899
+ const testCaseWorkItems = await this.fetchWorkItemsByIds(projectName, [...testCaseIds], true);
1900
+ for (const workItem of testCaseWorkItems || []) {
1901
+ const testCaseId = Number(workItem?.id || 0);
1902
+ if (!Number.isFinite(testCaseId) || testCaseId <= 0) continue;
1903
+ const relations = Array.isArray(workItem?.relations) ? workItem.relations : [];
1904
+ if (!relatedIdsByTestCase.has(testCaseId)) relatedIdsByTestCase.set(testCaseId, new Set<number>());
1905
+ for (const relation of relations) {
1906
+ const linkedWorkItemId = this.extractLinkedWorkItemIdFromRelation(relation);
1907
+ if (!linkedWorkItemId) continue;
1908
+ relatedIdsByTestCase.get(testCaseId)!.add(linkedWorkItemId);
1909
+ allRelatedIds.add(linkedWorkItemId);
1910
+
1911
+ if (this.isTestCaseToRequirementRelation(relation) && requirementById.has(linkedWorkItemId)) {
1912
+ const linkedRequirement = requirementById.get(linkedWorkItemId)!;
1913
+ const entry = ensure(testCaseId);
1914
+ entry.baseKeys.add(linkedRequirement.baseKey);
1915
+ entry.fullCodes.add(linkedRequirement.fullCode);
1916
+ }
1917
+ }
1918
+ }
1919
+ }
1920
+
1921
+ if (allRelatedIds.size > 0) {
1922
+ const relatedWorkItems = await this.fetchWorkItemsByIds(projectName, [...allRelatedIds], false);
1923
+ const typeById = new Map<number, string>();
1924
+ for (const workItem of relatedWorkItems || []) {
1925
+ const id = Number(workItem?.id || 0);
1926
+ if (!Number.isFinite(id) || id <= 0) continue;
1927
+ const type = String(workItem?.fields?.['System.WorkItemType'] || '')
1928
+ .trim()
1929
+ .toLowerCase();
1930
+ typeById.set(id, type);
1931
+ }
1932
+
1933
+ for (const [testCaseId, ids] of relatedIdsByTestCase.entries()) {
1934
+ const entry = ensure(testCaseId);
1935
+ for (const linkedId of ids) {
1936
+ const linkedType = typeById.get(linkedId) || '';
1937
+ if (linkedType === 'bug') {
1938
+ entry.bugIds.add(linkedId);
1939
+ }
1940
+ }
1941
+ }
1942
+ }
1943
+
1944
+ return map;
1945
+ }
1946
+
1947
+ private async resolveMewpRequirementScopeKeysFromQuery(
1948
+ linkedQueryRequest: any,
1949
+ requirements: Array<Pick<MewpL2RequirementWorkItem, 'workItemId' | 'baseKey'>>,
1950
+ linkedRequirementsByTestCase: MewpLinkedRequirementsByTestCase
1951
+ ): Promise<Set<string> | undefined> {
1952
+ const mode = String(linkedQueryRequest?.linkedQueryMode || '')
1953
+ .trim()
1954
+ .toLowerCase();
1955
+ const wiqlHref = String(linkedQueryRequest?.testAssociatedQuery?.wiql?.href || '').trim();
1956
+ if (mode !== 'query' || !wiqlHref) return undefined;
1957
+
1958
+ try {
1959
+ const queryResult = await TFSServices.getItemContent(wiqlHref, this.token);
1960
+ const queryIds = new Set<number>();
1961
+ if (Array.isArray(queryResult?.workItems)) {
1962
+ for (const workItem of queryResult.workItems) {
1963
+ const id = Number(workItem?.id || 0);
1964
+ if (Number.isFinite(id) && id > 0) queryIds.add(id);
1965
+ }
1966
+ }
1967
+ if (Array.isArray(queryResult?.workItemRelations)) {
1968
+ for (const relation of queryResult.workItemRelations) {
1969
+ const sourceId = Number(relation?.source?.id || 0);
1970
+ const targetId = Number(relation?.target?.id || 0);
1971
+ if (Number.isFinite(sourceId) && sourceId > 0) queryIds.add(sourceId);
1972
+ if (Number.isFinite(targetId) && targetId > 0) queryIds.add(targetId);
1973
+ }
1974
+ }
1975
+
1976
+ if (queryIds.size === 0) return undefined;
1977
+
1978
+ const reqIdToBaseKey = new Map<number, string>();
1979
+ for (const requirement of requirements || []) {
1980
+ const id = Number(requirement?.workItemId || 0);
1981
+ const baseKey = String(requirement?.baseKey || '').trim();
1982
+ if (id > 0 && baseKey) reqIdToBaseKey.set(id, baseKey);
1983
+ }
1984
+
1985
+ const scopedKeys = new Set<string>();
1986
+ for (const queryId of queryIds) {
1987
+ if (reqIdToBaseKey.has(queryId)) {
1988
+ scopedKeys.add(reqIdToBaseKey.get(queryId)!);
1989
+ continue;
1990
+ }
1991
+
1992
+ const linked = linkedRequirementsByTestCase.get(queryId);
1993
+ if (!linked?.baseKeys?.size) continue;
1994
+ linked.baseKeys.forEach((baseKey) => scopedKeys.add(baseKey));
1995
+ }
1996
+
1997
+ return scopedKeys.size > 0 ? scopedKeys : undefined;
1998
+ } catch (error: any) {
1999
+ logger.warn(`Could not resolve MEWP query scope: ${error?.message || error}`);
2000
+ return undefined;
2001
+ }
2002
+ }
2003
+
2004
+ private isTestCaseToRequirementRelation(relation: any): boolean {
2005
+ const rel = String(relation?.rel || '')
2006
+ .trim()
2007
+ .toLowerCase();
2008
+ if (!rel) return false;
2009
+ return rel.includes('testedby-reverse') || (rel.includes('tests') && rel.includes('reverse'));
2010
+ }
2011
+
2012
+ private extractLinkedWorkItemIdFromRelation(relation: any): number {
2013
+ const url = String(relation?.url || '');
2014
+ const match = /\/workItems\/(\d+)/i.exec(url);
2015
+ if (!match) return 0;
2016
+ const parsed = Number(match[1]);
2017
+ return Number.isFinite(parsed) ? parsed : 0;
859
2018
  }
860
2019
 
861
2020
  private async fetchMewpRequirementTypeNames(projectName: string): Promise<string[]> {
@@ -919,6 +2078,18 @@ ORDER BY [System.Id]`;
919
2078
  return [...out].sort((a, b) => a - b);
920
2079
  }
921
2080
 
2081
+ private extractLinkedWorkItemIdsFromRelations(relations: any[]): number[] {
2082
+ const out = new Set<number>();
2083
+ for (const relation of Array.isArray(relations) ? relations : []) {
2084
+ const url = String(relation?.url || '');
2085
+ const match = /\/workItems\/(\d+)/i.exec(url);
2086
+ if (!match) continue;
2087
+ const id = Number(match[1]);
2088
+ if (Number.isFinite(id) && id > 0) out.add(id);
2089
+ }
2090
+ return [...out].sort((a, b) => a - b);
2091
+ }
2092
+
922
2093
  private extractMewpRequirementIdentifier(fields: Record<string, any>, fallbackWorkItemId: number): string {
923
2094
  const entries = Object.entries(fields || {});
924
2095
 
@@ -938,7 +2109,7 @@ ORDER BY [System.Id]`;
938
2109
 
939
2110
  const valueAsString = this.toMewpComparableText(value);
940
2111
  if (!valueAsString) continue;
941
- const normalized = this.normalizeMewpRequirementCode(valueAsString);
2112
+ const normalized = this.normalizeMewpRequirementCodeWithSuffix(valueAsString);
942
2113
  if (normalized) return normalized;
943
2114
  }
944
2115
 
@@ -950,13 +2121,13 @@ ORDER BY [System.Id]`;
950
2121
 
951
2122
  const valueAsString = this.toMewpComparableText(value);
952
2123
  if (!valueAsString) continue;
953
- const normalized = this.normalizeMewpRequirementCode(valueAsString);
2124
+ const normalized = this.normalizeMewpRequirementCodeWithSuffix(valueAsString);
954
2125
  if (normalized) return normalized;
955
2126
  }
956
2127
 
957
2128
  // Optional fallback from title only (avoid scanning all fields and accidental SR matches).
958
2129
  const title = this.toMewpComparableText(fields?.['System.Title']);
959
- const titleCode = this.normalizeMewpRequirementCode(title);
2130
+ const titleCode = this.normalizeMewpRequirementCodeWithSuffix(title);
960
2131
  if (titleCode) return titleCode;
961
2132
 
962
2133
  return fallbackWorkItemId ? `SR${fallbackWorkItemId}` : '';
@@ -988,17 +2159,69 @@ ORDER BY [System.Id]`;
988
2159
  return '';
989
2160
  }
990
2161
 
2162
+ private deriveMewpSubSystem(fields: Record<string, any>): string {
2163
+ const directCandidates = [
2164
+ fields?.['Custom.SubSystem'],
2165
+ fields?.['Custom.Subsystem'],
2166
+ fields?.['SubSystem'],
2167
+ fields?.['Subsystem'],
2168
+ fields?.['subSystem'],
2169
+ ];
2170
+ for (const candidate of directCandidates) {
2171
+ const value = this.toMewpComparableText(candidate);
2172
+ if (value) return value;
2173
+ }
2174
+
2175
+ const keyHints = ['subsystem', 'sub system', 'sub_system'];
2176
+ for (const [key, value] of Object.entries(fields || {})) {
2177
+ const normalizedKey = String(key || '').toLowerCase();
2178
+ if (!keyHints.some((hint) => normalizedKey.includes(hint))) continue;
2179
+ const resolved = this.toMewpComparableText(value);
2180
+ if (resolved) return resolved;
2181
+ }
2182
+
2183
+ return '';
2184
+ }
2185
+
2186
+ private resolveBugResponsibility(fields: Record<string, any>): string {
2187
+ const sapWbsRaw = this.toMewpComparableText(fields?.['Custom.SAPWBS'] || fields?.['SAPWBS']);
2188
+ const fromSapWbs = this.resolveMewpResponsibility(sapWbsRaw);
2189
+ if (fromSapWbs === 'ESUK') return 'ESUK';
2190
+ if (fromSapWbs === 'IL') return 'Elisra';
2191
+
2192
+ const areaPathRaw = this.toMewpComparableText(fields?.['System.AreaPath']);
2193
+ const fromAreaPath = this.resolveMewpResponsibility(areaPathRaw);
2194
+ if (fromAreaPath === 'ESUK') return 'ESUK';
2195
+ if (fromAreaPath === 'IL') return 'Elisra';
2196
+
2197
+ return 'Unknown';
2198
+ }
2199
+
991
2200
  private resolveMewpResponsibility(value: string): string {
992
- const text = String(value || '')
993
- .trim()
994
- .toLowerCase();
995
- if (!text) return '';
2201
+ const raw = String(value || '').trim();
2202
+ if (!raw) return '';
2203
+
2204
+ const rawUpper = raw.toUpperCase();
2205
+ if (rawUpper === 'ESUK') return 'ESUK';
2206
+ if (rawUpper === 'IL') return 'IL';
2207
+
2208
+ const normalizedPath = raw
2209
+ .toLowerCase()
2210
+ .replace(/\//g, '\\')
2211
+ .replace(/\\+/g, '\\')
2212
+ .trim();
2213
+
2214
+ if (normalizedPath.endsWith('\\atp\\esuk') || normalizedPath === 'atp\\esuk') return 'ESUK';
2215
+ if (normalizedPath.endsWith('\\atp') || normalizedPath === 'atp') return 'IL';
996
2216
 
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
2217
  return '';
1000
2218
  }
1001
2219
 
2220
+ private isExcludedL3L4BySapWbs(value: string): boolean {
2221
+ const responsibility = this.resolveMewpResponsibility(this.toMewpComparableText(value));
2222
+ return responsibility === 'ESUK';
2223
+ }
2224
+
1002
2225
  private normalizeMewpRequirementCode(value: string): string {
1003
2226
  const text = String(value || '').trim();
1004
2227
  if (!text) return '';
@@ -1007,6 +2230,16 @@ ORDER BY [System.Id]`;
1007
2230
  return `SR${match[1]}`;
1008
2231
  }
1009
2232
 
2233
+ private normalizeMewpRequirementCodeWithSuffix(value: string): string {
2234
+ const text = String(value || '').trim();
2235
+ if (!text) return '';
2236
+ const compact = text.replace(/\s+/g, '');
2237
+ const match = /^SR(\d+)(?:-(\d+))?$/i.exec(compact);
2238
+ if (!match) return '';
2239
+ if (match[2]) return `SR${match[1]}-${match[2]}`;
2240
+ return `SR${match[1]}`;
2241
+ }
2242
+
1010
2243
  private toMewpComparableText(value: any): string {
1011
2244
  if (value === null || value === undefined) return '';
1012
2245
  if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {