diff --git a/CHANGELOG.md b/CHANGELOG.md index 25c01bdb..8bdbf7cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,11 @@ All notable changes to the "vectorcastTestExplorer" extension will be documented in this file. +## [1.0.29] - 2026-02-11 + +### Added +- Added a Test Review for Requirement Tests + ## [1.0.28] - 2026-01-27 ### Added diff --git a/package.json b/package.json index aaecc9e8..ce9d2058 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "vectorcasttestexplorer", "displayName": "VectorCAST Test Explorer", "description": "VectorCAST Test Explorer for VS Code", - "version": "1.0.28", + "version": "1.0.29", "license": "MIT", "repository": { "type": "git", @@ -229,6 +229,11 @@ "category": "VectorCAST Test Explorer", "title": "Open Source File under Test" }, + { + "command": "vectorcastTestExplorer.openReqsCoverageReview", + "category": "VectorCAST Test Explorer", + "title": "Open Requirements Coverage Review" + }, { "command": "vectorcastTestExplorer.insertBasisPathTests", "category": "VectorCAST Test Explorer", @@ -458,6 +463,12 @@ "type": "string", "description": "Path to folder containing Reqs2X executables (reqs2tests, code2reqs and panreq), if unset search for the executables in the main VectorCAST installation folder instead" }, + "vectorcastTestExplorer.reqs2x.enableRequirementsCoverageReview": { + "order": 2, + "type": "boolean", + "description": "Enable the Requirements Coverage Review feature (requires a Requirements Beta release)", + "default": false + }, "vectorcastTestExplorer.reqs2x.decomposeRequirements": { "type": "boolean", "order": 3, @@ -771,6 +782,10 @@ { "command": "vectorcastTestExplorer.openSourceFileFromTestpaneCommand", "when": "never" + }, + { + "command": "vectorcastTestExplorer.openReqsCoverageReview", + "when": "never" }, { "command": "vectorcastTestExplorer.insertBasisPathTests", @@ -1080,6 +1095,11 @@ "group": "vcast.enviroManagement", "when": "testId =~ /^vcast:.*$/ && !(testId =~ /^.*<>.*$/) && !(testId =~ /^.*<>.*$/) && !(testId =~ /.*coded_tests_driver.*/) && testId not in vectorcastTestExplorer.vcastUnbuiltEnviroList && testId not in vectorcastTestExplorer.vcastEnviroList" }, + { + "command": "vectorcastTestExplorer.openReqsCoverageReview", + "group": "vcast.enviroManagement", + "when": "testId =~ /^vcast:.*$/ && !(testId =~ /^.*<>.*$/) && !(testId =~ /^.*<>.*$/) && !(testId =~ /.*coded_tests_driver.*/) && testId not in vectorcastTestExplorer.vcastUnbuiltEnviroList && testId not in vectorcastTestExplorer.vcastEnviroList && testId in vectorcastTestExplorer.testNodesWithRequirements && config.vectorcastTestExplorer.reqs2x.enableRequirementsCoverageReview" + }, { "command": "vectorcastTestExplorer.insertBasisPathTests", "group": "vcast.testGeneration", diff --git a/python/vTestInterface.py b/python/vTestInterface.py index 0e5f1bc6..dc9a2641 100644 --- a/python/vTestInterface.py +++ b/python/vTestInterface.py @@ -155,6 +155,7 @@ def generateTestInfo(enviroPath, test): testInfo["time"] = getTime(test.start_time) testInfo["status"] = textStatus(test.status) testInfo["passfail"] = getPassFailString(test) + testInfo["requirements"] = str(test.requirements) # New to support coded tests in vc24 if vpythonHasCodedTestSupport() and test.coded_tests_file: @@ -302,6 +303,7 @@ def getUnitData(api): unitInfo["covered"] = covered unitInfo["uncovered"] = uncovered unitInfo["partiallyCovered"] = partiallyCovered + unitInfo["perTestCoverage"] = getPerTestCoverageData(sourceObject) unitList.append(unitInfo) elif len(sourcePath) > 0: @@ -315,6 +317,7 @@ def getUnitData(api): unitInfo["covered"] = "" unitInfo["uncovered"] = "" unitInfo["partiallyCovered"] = "" + unitInfo["perTestCoverage"] = {} unitList.append(unitInfo) return unitList @@ -390,6 +393,332 @@ def getCoverageKind(sourceObject): return CoverageKind.ignore +def _buildResultNameCache(sourceObject): + """ + Pre-builds a mapping from Result.id to test case identifier string. + This avoids repeated result.unit_test lookups when the same Result + appears on many lines during per-test coverage collection. + + Returns: dict {result_id: "unit.function.testname"} + """ + cache = {} + try: + for result in sourceObject.cover_data.results: + test_case = result.unit_test + if test_case: + name = ( + f"{test_case.unit_display_name}" + f".{test_case.function_display_name}" + f".{test_case.name}" + ) + cache[result.id] = name + else: + # Fallback for non-unit-test results (e.g. cover-only or imported) + cache[result.id] = result.name + except Exception: + # If the API doesn't support this (older DataAPI versions), return empty + pass + return cache + + +def _buildBranchAndMcdcByLine(sourceObject): + """ + Iterates the InstrumentedFile's LIS data to build mappings from + source line numbers to Branch and MCDCDecision objects. + + Returns: (branches_by_line, mcdc_by_line) + branches_by_line: {int line_number: [Branch, ...]} + mcdc_by_line: {int line_number: MCDCDecision} + """ + branches_by_line = {} + mcdc_by_line = {} + try: + for instrumented_file in sourceObject.cover_data.instrumented_files: + for lis_data in instrumented_file.iterate_coverage(): + line_num_str = lis_data.line_number + if not line_num_str: + continue + line_num = int(line_num_str) + if lis_data.branch is not None: + branches_by_line.setdefault(line_num, []).append(lis_data.branch) + if lis_data.mcdc is not None: + mcdc_by_line[line_num] = lis_data.mcdc + except Exception: + pass + return branches_by_line, mcdc_by_line + + +def _resultCoversBranch(branch, result_id): + """ + Checks how many of the branch's directions (T/F) are covered by a + specific result. Returns (covered_count, total_count). + + A Branch has num_conditions == 1 (T-only or F-only) or 2 (both T and F). + """ + total = branch.num_conditions + covered = 0 + try: + true_ids = {r.id for r in branch.get_true_results()} + if result_id in true_ids: + covered += 1 + except Exception: + pass + if total >= 2: + try: + false_ids = {r.id for r in branch.get_false_results()} + if result_id in false_ids: + covered += 1 + except Exception: + pass + return covered, total + + +def _resultCoversMcdcDecision(mcdc, result_id): + """ + Checks how a specific result covers an MCDC decision. + Returns (covered_pairs, total_pairs, covered_branches, total_branches). + + For per-result MCDC coverage, we check: + 1. Branch directions of the decision (T/F via mcdc.branch or mcdc.conditions + where is_branch==True) + 2. Independence pairs for each condition + """ + covered_pairs = 0 + total_pairs = 0 + covered_branches = 0 + total_branches = 0 + + try: + for condition in mcdc.conditions: + if condition.is_branch: + # This is the T/F branch for the decision itself + nc = condition.num_conditions + total_branches += nc + try: + if result_id in {r.id for r in condition.get_true_results()}: + covered_branches += 1 + except Exception: + pass + if nc >= 2: + try: + if result_id in {r.id for r in condition.get_false_results()}: + covered_branches += 1 + except Exception: + pass + else: + # This is an actual MCDC condition — check its pairs + try: + for pair in condition.covered_pairs: + total_pairs += 1 + try: + pair_result_ids = {r.id for r in pair.get_results()} + if result_id in pair_result_ids: + covered_pairs += 1 + except Exception: + pass + except Exception: + pass + except Exception: + pass + + return covered_pairs, total_pairs, covered_branches, total_branches + + +def _classifyResultForLine( + result_id, + line_number, + coverageKind, + branches_by_line, + mcdc_by_line, + functionLineSet, +): + """ + Classifies a single result's coverage on a single line. + Returns "covered", "partiallyCovered", or None (if the result + doesn't meaningfully cover the line for this coverage kind). + + Mirrors the classification logic in coverageGutter.py but for + a single result instead of aggregate metrics. + """ + + if coverageKind == CoverageKind.statement: + # Statement-only: hitting the line means covered (binary) + return "covered" + + elif coverageKind == CoverageKind.statementBranch: + # Statement + Branch + branches = branches_by_line.get(line_number) + if branches: + total_dirs = 0 + covered_dirs = 0 + for branch in branches: + c, t = _resultCoversBranch(branch, result_id) + covered_dirs += c + total_dirs += t + if total_dirs == 0: + return "covered" + elif covered_dirs == total_dirs: + return "covered" + elif covered_dirs > 0: + return "partiallyCovered" + else: + # Result hit the line (is in line.results) but covered + # no branch directions — still a statement hit on a branch line. + # The aggregate handler classifies this as uncovered (red) + # because no branches are covered, so we follow suit. + return None + else: + # Pure statement line, no branches — hitting it means covered + return "covered" + + elif coverageKind == CoverageKind.branch: + # Branch-only (no statement coverage) + if line_number in functionLineSet: + # Function header lines are filtered out for branch-only + return None + branches = branches_by_line.get(line_number) + if branches: + total_dirs = 0 + covered_dirs = 0 + for branch in branches: + c, t = _resultCoversBranch(branch, result_id) + covered_dirs += c + total_dirs += t + if total_dirs == 0: + return None + elif covered_dirs == total_dirs: + return "covered" + elif covered_dirs > 0: + return "partiallyCovered" + else: + return None + else: + # Non-branch line in branch-only mode — not coverable + return None + + elif coverageKind == CoverageKind.statementMcdc: + # Statement + MCDC + mcdc = mcdc_by_line.get(line_number) + if mcdc is not None: + # MCDC decision line — check branches + pairs + cp, tp, cb, tb = _resultCoversMcdcDecision(mcdc, result_id) + total = tp + tb + covered = cp + cb + if total == 0: + # No coverable branches/pairs on this decision + return "covered" + elif covered == total: + return "covered" + elif covered > 0: + return "partiallyCovered" + else: + # Result hit the statement but no branch/pair coverage + # Aggregate handler treats this as uncovered for MCDC lines + return None + else: + # Non-MCDC statement line — hitting means covered + return "covered" + + elif coverageKind == CoverageKind.mcdc: + # MCDC-only (no statement coverage) + mcdc = mcdc_by_line.get(line_number) + if mcdc is not None: + cp, tp, cb, tb = _resultCoversMcdcDecision(mcdc, result_id) + total = tp + tb + covered = cp + cb + if total == 0: + return None + elif covered == total: + return "covered" + elif covered > 0: + return "partiallyCovered" + else: + return None + else: + # Non-MCDC line in MCDC-only mode — not coverable + return None + + # Unknown coverage kind + return None + + +def getPerTestCoverageData(sourceObject): + """ + Returns a dict mapping line numbers to dicts of test case coverage + statuses. Uses SourceLine.results and Branch/MCDC per-result APIs + from the DataAPI to determine coverage classification per test. + + Format: {"lineNum": {"unit.function.testname": "covered"|"partiallyCovered", ...}, ...} + Only lines with at least one covering test are included. + Status is "covered" when the test covers all coverable objectives on + the line, "partiallyCovered" when it covers some but not all (branch + directions or MCDC pairs). + + Returns an empty dict if the source is not instrumented, the file + doesn't exist on disk, or the API doesn't support per-result queries. + """ + perTestCoverage = {} + if not (sourceObject and sourceObject.is_instrumented): + return perTestCoverage + + if not os.path.exists(sourceObject.path): + return perTestCoverage + + resultNameCache = _buildResultNameCache(sourceObject) + if not resultNameCache: + return perTestCoverage + + coverageKind = getCoverageKind(sourceObject) + if coverageKind == CoverageKind.ignore: + return perTestCoverage + + # Pre-build branch/MCDC-by-line mappings for non-statement-only kinds + branches_by_line = {} + mcdc_by_line = {} + if coverageKind in ( + CoverageKind.branch, + CoverageKind.statementBranch, + CoverageKind.mcdc, + CoverageKind.statementMcdc, + ): + branches_by_line, mcdc_by_line = _buildBranchAndMcdcByLine(sourceObject) + + # Build function start line set for branch-only filtering + functionLineSet = set() + if coverageKind == CoverageKind.branch: + for function in sourceObject.cover_data.functions: + functionLineSet.add(function.start_line) + + try: + for line in sourceObject.iterate_coverage(): + lineResults = line.results + if not lineResults: + continue + line_num = line.line_number + testStatuses = {} + for result in lineResults: + name = resultNameCache.get(result.id) + if not name: + continue + status = _classifyResultForLine( + result.id, + line_num, + coverageKind, + branches_by_line, + mcdc_by_line, + functionLineSet, + ) + if status: + testStatuses[name] = status + if testStatuses: + perTestCoverage[str(line_num)] = testStatuses + except Exception: + # Gracefully degrade if APIs are not available + pass + + return perTestCoverage + + def getCoverageData(sourceObject): """ This function will use the data interface to diff --git a/src/coverage.ts b/src/coverage.ts index e4618054..68694197 100644 --- a/src/coverage.ts +++ b/src/coverage.ts @@ -6,6 +6,7 @@ import { } from "vscode"; import { getCoverageDataForFile, + getCoverageDataForFileAndTest, getListOfFilesWithCoverage, } from "./vcastTestInterface"; @@ -183,8 +184,11 @@ export async function updateCOVdecorations() { ) { const filePath = url.fileURLToPath(activeEditor.document.uri.toString()); - // this returns the cached coverage data for this file - const coverageData = getCoverageDataForFile(filePath); + // In review mode, show only the selected test's coverage for the target file + const coverageData = + reviewModeActive && reviewModeTestId && reviewModeFilePath === filePath + ? getCoverageDataForFileAndTest(filePath, reviewModeTestId) + : getCoverageDataForFile(filePath); if (coverageData.hasCoverageData) { // there is coverage data and it matches the file checksum @@ -331,3 +335,30 @@ export async function toggleCoverageAction() { export async function updateDisplayedCoverage() { if (coverageOn) await updateCOVdecorations(); } + +// Review mode state //////////////////////////////////////////////// +let reviewModeActive: boolean = false; +let reviewModeTestId: string | null = null; +let reviewModeFilePath: string | null = null; +///////////////////////////////////////////////////////////////////// + +export function isReviewModeActive(): boolean { + return reviewModeActive; +} + +export function enterReviewMode(testId: string, filePath: string): void { + reviewModeActive = true; + reviewModeTestId = testId; + reviewModeFilePath = filePath; +} + +export async function exitReviewMode(): Promise { + reviewModeActive = false; + reviewModeTestId = null; + reviewModeFilePath = null; + + // Refresh normal coverage display + if (coverageOn) { + await updateCOVdecorations(); + } +} diff --git a/src/editorDecorator.ts b/src/editorDecorator.ts index 9719e6a0..05853031 100644 --- a/src/editorDecorator.ts +++ b/src/editorDecorator.ts @@ -146,6 +146,8 @@ export function buildTestNodeForFunction(args: any): testNodeType | undefined { enviroName: unitData.enviroName, unitName: unitData.unitName, functionName: functionName, + requirements: "", + notes: "", testName: "", testFile: "", testStartLine: 0, diff --git a/src/extension.ts b/src/extension.ts index 7d859e6c..22df7ab8 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -68,6 +68,7 @@ import { setGlobalProjectIsOpenedChecker, setGlobalCompilerAndTestsuites, loadTestScriptButton, + runTests, } from "./testPane"; import { @@ -125,12 +126,17 @@ import { } from "./vcastInstallation"; import { + activeHighlightDecoration, + setActiveHighlightDecoration, findRelevantRequirementGateway, generateRequirementsHtml, + openSourceFileWithHighlight, + openTstScriptAtTest, parseRequirementsFromFile, performLLMProviderUsableCheck, requirementsFileWatcher, updateRequirementsAvailability, + fetchRequirementCoverageData, } from "./requirements/requirementsUtils"; import { @@ -165,6 +171,7 @@ import { import fs = require("fs"); import { compilerTagList, + findTestItemInController, getNonce, resolveWebviewBase, setCompilerList, @@ -507,6 +514,19 @@ function configureExtension(context: vscode.ExtensionContext) { enviroPath, testNode.functionName || testNode.unitName || null ); + + // We need to execute the test and refresh the extension data so that the tests get the + // Reqs review button (see openReqsCoverageReview). On normal envs, after generating the tests, + // we fetch the data from the env but the tests still not seem to have the data. + // Executing them and refreshing the extension solves this. + const testItem = findTestItemInController(testNode.enviroNodeID); + if (testItem) { + const request = new vscode.TestRunRequest([testItem]); + await runTests(request, new vscode.CancellationTokenSource().token); + } else { + vectorMessage(`TestItem for ${testNode.enviroNodeID} not found`); + } + await refreshAllExtensionData(); } } ); @@ -1308,6 +1328,110 @@ function configureExtension(context: vscode.ExtensionContext) { ); context.subscriptions.push(openSourceFileFromTestpaneCommand); + let openReqsCoverageReview = vscode.commands.registerCommand( + "vectorcastTestExplorer.openReqsCoverageReview", + async (args: any) => { + if (!args) return; + + const testNode: testNodeType = getTestNode(args.id); + if (!testNode) { + vscode.window.showWarningMessage( + `Failed to retrieve node test data for ${args.id}. Aborting Test Review.` + ); + return; + } + + const { enviroPath, unitName, functionName } = testNode; + const envData = await getEnvironmentData(enviroPath); + const envRGWPath = findRelevantRequirementGateway(enviroPath); + const testName = testNode.testName; + + if (!envData?.unitData || !envRGWPath || !testName) { + vscode.window.showWarningMessage( + "Failed to retrieve environment test data. Aborting Test Review." + ); + return; + } + + // Find the matching unit's source file + const matchingUnit = envData.unitData.find((unit: { path: string }) => { + if (!unit.path) return false; + const unitBaseName = path.basename(unit.path, path.extname(unit.path)); + return unitBaseName === unitName; + }); + + if (!matchingUnit?.path) { + vscode.window.showWarningMessage( + `Could not find source file for unit: ${unitName}` + ); + return; + } + + // Determine the line number to open at + let lineNumber = 0; + if (functionName && matchingUnit.functionList) { + for (const func of matchingUnit.functionList) { + if (func.name === functionName && func.startLine !== undefined) { + lineNumber = func.startLine; + break; + } + } + } + + const reqData = await fetchRequirementCoverageData( + enviroPath, + envRGWPath, + testName, + unitName, + testNode, + lineNumber + ); + + if (!reqData) { + vscode.window.showWarningMessage( + "Failed to retrieve requirements test data. Aborting Test Review." + ); + return; + } + + // Close sidebar for more screen space + await vscode.commands.executeCommand("workbench.action.closeSidebar"); + + // Use the test node ID directly from the test explorer + const testId = args.id; + + // Open source file with requirement highlighting + await openSourceFileWithHighlight( + matchingUnit.path, + reqData, + context, + testId + ); + + // Open TST script beside source file + const scriptPath = testNode.enviroPath + ".tst"; + await openTstScriptAtTest(testNode, scriptPath); + + vscode.window.showWarningMessage( + `You are currently in Review Mode for the requirement test ${testName}. Only coverage for this test is shown. To exit Review Mode, close the requirement description box in the source file.` + ); + } + ); + + let closeRequirementBoxes = vscode.commands.registerCommand( + "vectorcastTestExplorer.closeRequirementBoxes", + () => { + if (activeHighlightDecoration) { + activeHighlightDecoration.dispose(); + setActiveHighlightDecoration(null); + } + } + ); + + // Register commands + context.subscriptions.push(openReqsCoverageReview); + context.subscriptions.push(closeRequirementBoxes); + let showRequirementsCommand = vscode.commands.registerCommand( "vectorcastTestExplorer.showRequirements", async (args: any) => { diff --git a/src/requirements/requirementsOperations.ts b/src/requirements/requirementsOperations.ts index ceab16d7..dad1dcb3 100644 --- a/src/requirements/requirementsOperations.ts +++ b/src/requirements/requirementsOperations.ts @@ -7,7 +7,7 @@ import { spawnWithVcastEnv, updateRequirementsAvailability, } from "./requirementsUtils"; -import { refreshAllExtensionData } from "../testPane"; +import { refreshAllExtensionData, updateTestPane } from "../testPane"; import { loadTestScriptIntoEnvironment } from "../vcastAdapter"; const path = require("path"); @@ -22,6 +22,7 @@ let CODE2REQS_EXECUTABLE_PATH: string; let REQS2TESTS_EXECUTABLE_PATH: string; let PANREQ_EXECUTABLE_PATH: string; +export let TEST2CHECK_EXECUTABLE_PATH: string; export let LLM2CHECK_EXECUTABLE_PATH: string; // Add a new output channel for CLI operations @@ -173,6 +174,10 @@ function setupReqs2XExecutablePaths(context: vscode.ExtensionContext): boolean { baseUri, exeFilename("llm2check") ).fsPath; + TEST2CHECK_EXECUTABLE_PATH = vscode.Uri.joinPath( + baseUri, + exeFilename("tests2check") + ).fsPath; return true; } @@ -455,7 +460,7 @@ export async function generateTestsFromRequirements( `reqs2tests completed successfully with code ${code}` ); await loadTestScriptIntoEnvironment(envName.split(".")[0], tstPath); - await refreshAllExtensionData(); + await updateTestPane(enviroPath); vscode.window.showInformationMessage( "Successfully generated tests for the requirements!" diff --git a/src/requirements/requirementsUtils.ts b/src/requirements/requirementsUtils.ts index baf2cc48..0f2245c0 100644 --- a/src/requirements/requirementsUtils.ts +++ b/src/requirements/requirementsUtils.ts @@ -8,8 +8,17 @@ import { LLM2CHECK_EXECUTABLE_PATH, logCliError, logCliOperation, + TEST2CHECK_EXECUTABLE_PATH, } from "./requirementsOperations"; import { makeEnviroNodeID } from "../testPane"; +import { dumpTestScriptFile } from "../vcastAdapter"; +import { convertTestScriptContents } from "../vcastUtilities"; +import { testNodeType } from "../testData"; +import { + enterReviewMode, + exitReviewMode, + updateDisplayedCoverage, +} from "../coverage"; const path = require("path"); const fs = require("fs"); @@ -362,8 +371,8 @@ export function isLLMProviderEnvironmentUsable(): Promise<{ const result = JSON.parse(output); resolve({ usable: result.usable, problem: result.problem || null }); } catch (e) { - console.error(`Failed to parse llm2check output: ${e}`); - resolve({ usable: false, problem: "Failed to parse llm2check output" }); + console.error(`Failed to parse llm2check output: ${e} ${output}`); + resolve({ usable: false, problem: `Failed to parse llm2check output: ${output}` }); } }); }); @@ -594,3 +603,522 @@ export function expandEnvVars(inputPath: string): string { return value; }); } + +export interface RequirementData { + title: string; + description: string; + lineNumber: number; + importantLineStart: number; + importantLineEnd: number; + coverageStatus: string; + expectedLines: number[]; + actualLines: number[]; +} + +// State Management +export let activeHighlightDecoration: vscode.TextEditorDecorationType | null = + null; + +/** + * Wraps a single line of text into an array of lines, each <= maxWidth chars + */ +function wrapText(text: string, maxWidth: number): string[] { + if (text.length <= maxWidth) { + return [text]; + } + + const lines: string[] = []; + const words = text.split(" "); + let current = ""; + + for (const word of words) { + // Word itself is longer than maxWidth — hard break it + if (word.length > maxWidth) { + if (current) { + lines.push(current); + current = ""; + } + let remaining = word; + while (remaining.length > maxWidth) { + lines.push(remaining.slice(0, maxWidth)); + remaining = remaining.slice(maxWidth); + } + current = remaining; + continue; + } + + const candidate = current ? `${current} ${word}` : word; + if (candidate.length <= maxWidth) { + current = candidate; + } else { + lines.push(current); + current = word; + } + } + + if (current) { + lines.push(current); + } + + return lines; +} + +/** + * Creates a formatted text box containing requirement information. + * Guarantees nothing escapes the box — title and description are both wrapped. + */ +export function createRequirementInfoBox(reqData: RequirementData): string { + const BOX_WIDTH = 66; // total inner width (between the ║ borders) + const PADDING = 2; // spaces on each side inside the border + const TEXT_WIDTH = BOX_WIDTH - PADDING * 2; // usable text width = 62 + + const topBorder = "╔" + "═".repeat(BOX_WIDTH) + "╗"; + const midBorder = "╠" + "═".repeat(BOX_WIDTH) + "╣"; + const bottomBorder = "╚" + "═".repeat(BOX_WIDTH) + "╝"; + const divider = "║ " + "─".repeat(TEXT_WIDTH) + " ║"; + + const formatLine = (text = ""): string => { + // Should never exceed TEXT_WIDTH after wrapping + const safe = text.length > TEXT_WIDTH ? text.slice(0, TEXT_WIDTH) : text; + return "║ " + safe.padEnd(TEXT_WIDTH) + " ║"; + }; + + // Wraps a block of text (may contain \n) and returns boxed lines + const formatBlock = (text: string): string[] => { + const lines: string[] = []; + + for (const paragraph of text.split(/\r?\n/)) { + if (!paragraph.trim()) { + lines.push(formatLine()); + continue; + } + for (const wrapped of wrapText(paragraph, TEXT_WIDTH)) { + lines.push(formatLine(wrapped)); + } + } + + return lines; + }; + + return [ + "", + topBorder, + ...formatBlock(reqData.title), + midBorder, + formatLine("DESCRIPTION"), + divider, + ...formatBlock(reqData.description), + bottomBorder, + "", + ].join("\n"); +} + +/** + * Finds the line number containing the test name in the TST script + */ +export function findTestNameLine(tstContent: string, testName: string): number { + const lines = tstContent.split("\n"); + const searchPattern = `TEST.NAME:${testName}`; + + for (let i = 0; i < lines.length; i++) { + if (lines[i].includes(searchPattern)) { + return i; + } + } + + return 0; // Default to top of file if not found +} + +// Decoration Types for highlighted critical lines +let activeUncoveredDecoration: vscode.TextEditorDecorationType | undefined; +let activeCoveredDecoration: vscode.TextEditorDecorationType | undefined; + +/** + * Creates a decoration type for a given coverage state + */ +function createCoverageDecoration( + bgColor: string +): vscode.TextEditorDecorationType { + return vscode.window.createTextEditorDecorationType({ + backgroundColor: bgColor, + isWholeLine: true, + }); +} + +/** + * Converts a 1-based line number to a single-line vscode.Range + */ +function lineToRange( + document: vscode.TextDocument, + lineNumber: number +): vscode.Range { + const line = lineNumber - 1; // Convert to 0-based + return new vscode.Range( + new vscode.Position(line, 0), + new vscode.Position(line, document.lineAt(line).text.length) + ); +} + +/** + * Highlights critical lines individually based on coverage status + * Uses test-specific actualLines from tests2check rather than global coverage + */ +export function highlightCriticalLines( + editor: vscode.TextEditor, + document: vscode.TextDocument, + reqData: RequirementData +): void { + // Dispose all previous decorations via shared helper + disposeCriticalLineDecorations(); + + if (!reqData.actualLines || reqData.actualLines.length === 0) { + return; + } + + // --- Decoration types --- + activeUncoveredDecoration = createCoverageDecoration( + "rgba(243, 74, 51, 0.15)", // red bg + "#f34a33" // red gutter + ); + activeCoveredDecoration = createCoverageDecoration( + "rgba(87, 184, 89, 0.15)", // green bg + "#57b859" // green gutter + ); + + // Build a Set of critical line numbers for fast lookup (1-based) + const criticalLines = new Set(); + for ( + let line = reqData.importantLineStart; + line <= reqData.importantLineEnd; + line++ + ) { + criticalLines.add(line); + } + + // actualLines from tests2check are 0-based, convert to 1-based + const actualLinesSet = new Set( + reqData.actualLines.map((line) => line + 1) + ); + + // expectedLines from tests2check are 0-based, convert to 1-based + const expectedLinesSet = new Set( + (reqData.expectedLines || []).map((line) => line + 1) + ); + + const uncoveredRanges: vscode.Range[] = []; + const coveredRanges: vscode.Range[] = []; + + for (const line of criticalLines) { + // Guard: skip lines beyond the document + if (line > document.lineCount) { + continue; + } + + const range = lineToRange(document, line); + + if (actualLinesSet.has(line)) { + coveredRanges.push(range); + } else if (expectedLinesSet.has(line)) { + uncoveredRanges.push(range); + } + // Lines not in expectedLines or actualLines are non-executable — skip them + } + + // Apply both decoration sets in one pass + editor.setDecorations(activeUncoveredDecoration, uncoveredRanges); + editor.setDecorations(activeCoveredDecoration, coveredRanges); +} + +/** + * Shows the requirement info box as a peek window + * Automatically clears highlights when the peek window is closed + */ +export async function showRequirementPeekBox( + sourceFileUri: vscode.Uri, + peekPosition: vscode.Position, + reqData: RequirementData, + context: vscode.ExtensionContext +): Promise { + const virtualDocUri = vscode.Uri.parse("requirement-info:Requirement Info"); + const infoBoxContent = createRequirementInfoBox(reqData); + + // Create content provider for the virtual document + const provider = new (class implements vscode.TextDocumentContentProvider { + provideTextDocumentContent(): string { + return infoBoxContent; + } + })(); + + const providerDisposable = + vscode.workspace.registerTextDocumentContentProvider( + "requirement-info", + provider + ); + + await vscode.workspace.openTextDocument(virtualDocUri); + + // Show peek window + await vscode.commands.executeCommand( + "editor.action.peekLocations", + sourceFileUri, + peekPosition, + [new vscode.Location(virtualDocUri, new vscode.Position(0, 0))], + "peek" + ); + + // Disable line numbers in peek window + setTimeout(() => { + for (const editor of vscode.window.visibleTextEditors) { + if (editor.document.uri.scheme === "requirement-info") { + editor.options = { + ...editor.options, + lineNumbers: vscode.TextEditorLineNumbersStyle.Off, + }; + } + } + }, 0); + + // Listen for when the peek window is closed and clear highlights + exit review mode + const disposable = vscode.window.onDidChangeVisibleTextEditors( + async (editors) => { + const peekWindowOpen = editors.some( + (editor) => editor.document.uri.scheme === "requirement-info" + ); + + if (!peekWindowOpen) { + // Clear ALL coverage highlight decorations + disposeCriticalLineDecorations(); + + // Also clear the legacy single decoration if somehow still set + if (activeHighlightDecoration) { + activeHighlightDecoration.dispose(); + setActiveHighlightDecoration(null); + } + + // Exit review mode and refresh normal coverage + await exitReviewMode(); + updateDisplayedCoverage(); + + // Clean up this listener + disposable.dispose(); + } + } + ); + context.subscriptions.push(disposable); + + // Clean up provider after peek window is shown + setTimeout(() => { + providerDisposable.dispose(); + }, 1000); +} + +/** + * Disposes all active critical line decorations + */ +export function disposeCriticalLineDecorations(): void { + activeUncoveredDecoration?.dispose(); + activeCoveredDecoration?.dispose(); + activeUncoveredDecoration = undefined; + activeCoveredDecoration = undefined; +} + +/** + * Opens the source file with requirement highlighting + */ +export async function openSourceFileWithHighlight( + sourceFilePath: string, + reqData: RequirementData, + context: vscode.ExtensionContext, + testId: string +): Promise { + const sourceFileUri = vscode.Uri.file(sourceFilePath); + const document = await vscode.workspace.openTextDocument(sourceFileUri); + + // Position cursor near the requirement line + const peekPosition = new vscode.Position( + Math.max(0, reqData.lineNumber - 1), + 0 + ); + + // Open document + const editor = await vscode.window.showTextDocument(document, { + preview: false, + preserveFocus: false, + selection: new vscode.Range(peekPosition, peekPosition), + }); + + // Enter review mode BEFORE applying decorations + enterReviewMode(testId, sourceFilePath); + + // Apply the green highlight to critical lines + highlightCriticalLines(editor, document, reqData); + + // Show the peek box + await showRequirementPeekBox(sourceFileUri, peekPosition, reqData, context); + + // Update coverage decorations to show review mode coverage + await updateDisplayedCoverage(); +} + +/** + * Opens the TST script and jumps to the test definition + */ +export async function openTstScriptAtTest( + testNode: testNodeType, + scriptPath: string +): Promise { + const commandStatus = await dumpTestScriptFile(testNode, scriptPath); + + if (commandStatus.errorCode !== 0) { + return; + } + + convertTestScriptContents(scriptPath); + + const tstScriptUri = vscode.Uri.file(scriptPath); + const tstDocument = await vscode.workspace.openTextDocument(tstScriptUri); + + // Find the test name line in the script + let targetLine = 0; + if (testNode.testName) { + const tstContent = tstDocument.getText(); + targetLine = findTestNameLine(tstContent, testNode.testName); + } + + // Open document and jump to test definition + const position = new vscode.Position(targetLine, 0); + const selection = new vscode.Range(position, position); + + await vscode.window.showTextDocument(tstDocument, { + viewColumn: vscode.ViewColumn.Beside, + selection: selection, + preview: false, + preserveFocus: false, + }); +} + +export function setActiveHighlightDecoration( + decoration: vscode.TextEditorDecorationType | null +): void { + activeHighlightDecoration = decoration; +} + +/** + * Fetches the requirements Data for a specific Test + */ +export async function fetchRequirementCoverageData( + enviroPath: string, + envRGWPath: string, + testName: string, + unitName: string, + testNode: testNodeType, + functionStartLine: number = 0 +): Promise { + return vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: "Retrieving requirement coverage data", + cancellable: false, + }, + async (progress) => { + progress.report({ message: "Running test2check…" }); + + const commandArgs = [ + "-e", + enviroPath, + envRGWPath, + "-f", + testName, + "--json", + ]; + + try { + const process = await spawnWithVcastEnv( + TEST2CHECK_EXECUTABLE_PATH, + commandArgs + ); + + const stdoutData: string[] = []; + const stderrData: string[] = []; + + process.stdout.on("data", (data) => { + stdoutData.push(data.toString()); + }); + + process.stderr.on("data", (data) => { + stderrData.push(data.toString()); + logCliError(`test2check: ${data.toString()}`); + }); + + await new Promise((resolve, reject) => { + process.on("close", (code: number) => { + if (code === 0) { + resolve(); + } else { + reject(new Error(`test2check exited with code ${code}`)); + } + }); + }); + + progress.report({ message: "Processing coverage results…" }); + + const output = stdoutData.join(""); + if (!output.trim()) return null; + + const jsonData = JSON.parse(output); + if (!Array.isArray(jsonData) || jsonData.length === 0) return null; + + if (jsonData.length > 1) { + vscode.window.showInformationMessage( + "This test is associated with multiple requirements. Coverage Review currently supports only one requirement per test." + ); + return null; + } + + const testResult = jsonData[0]; + const expectedCoverage = testResult.expected_coverage || {}; + const actualCoverage = testResult.actual_coverage || []; + + const unitCoverage = actualCoverage.find((cov: any) => { + const covUnitBase = path.basename(cov.unit, path.extname(cov.unit)); + return covUnitBase === unitName || cov.unit === unitName; + }); + + let expectedLines: number[] = []; + for (const funcKey in expectedCoverage) { + const funcCoverage = expectedCoverage[funcKey]; + if (Array.isArray(funcCoverage)) { + const unitExpected = funcCoverage.find( + (cov: any) => cov.unit === unitName + ); + if (unitExpected?.lines) { + expectedLines = unitExpected.lines; + } + } + } + + const minLine = expectedLines.length ? Math.min(...expectedLines) : 0; + const maxLine = expectedLines.length ? Math.max(...expectedLines) : 0; + + return { + title: testResult.name || testName, + description: `${testNode.notes}`, + lineNumber: functionStartLine, + // + 1 Because Critical LInes are 0 Indexed + importantLineStart: minLine + 1, + importantLineEnd: maxLine + 1, + coverageStatus: "covered", + expectedLines, + actualLines: unitCoverage?.lines || [], + }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + vscode.window.showWarningMessage( + `Failed to get requirement data: ${msg}` + ); + logCliError(msg); + return null; + } + } + ); +} diff --git a/src/testData.ts b/src/testData.ts index d1d01b1d..6863ae88 100644 --- a/src/testData.ts +++ b/src/testData.ts @@ -42,6 +42,8 @@ export interface testNodeType { // initially will be used for coded-tests testFile: string; testStartLine: number; + requirements: string; + notes: string; } // this is a lookup table for the nodes in the test tree // the key is the nodeID, the data is an testNodeType @@ -58,6 +60,8 @@ export function createTestNodeInCache( functionName: string = "", testName: string = "", testFile: string = "", + notes: string = "", + requirements: string = "", testStartLine: number = 1 ) { let testNode: testNodeType = { @@ -68,6 +72,8 @@ export function createTestNodeInCache( functionName: functionName, testName: testName, testFile: testFile, + notes: notes, + requirements: requirements, testStartLine: testStartLine, }; // set will over-write if nodeID exists diff --git a/src/testPane.ts b/src/testPane.ts index 4d6183cf..a0815ca4 100644 --- a/src/testPane.ts +++ b/src/testPane.ts @@ -165,6 +165,7 @@ function addTestNodes( passfail: testList[testIndex].passfail, time: testList[testIndex].time, notes: testList[testIndex].notes, + requirements: testList[testIndex].requirements, resultFilePath: "", stdout: "", compoundOnly: testList[testIndex].compoundOnly, @@ -186,10 +187,17 @@ function addTestNodes( testNodeForCache.testName = testName; testNodeForCache.testFile = testData.testFile; testNodeForCache.testStartLine = testData.testStartLine; + testNodeForCache.notes = testData.notes; + testNodeForCache.requirements = testData.requirements; addTestNodeToCache(testNodeID, testNodeForCache); globalTestStatusArray[testNodeID] = testData; + // Empty list --> No requirements --> Button should not appear on that Test Node + if (testNodeForCache.requirements !== "[]") { + testNodesWithRequirements.push(testNodeID); + } + // currently we only use the Uri and Range for Coded Tests let testURI: vscode.Uri | undefined = undefined; let testRange: vscode.Range | undefined = undefined; @@ -234,6 +242,12 @@ function addTestNodes( vcastHasCodedTestsList ); + vscode.commands.executeCommand( + "setContext", + "vectorcastTestExplorer.testNodesWithRequirements", + testNodesWithRequirements + ); + addTestNodeToCache(parentNodeID, parentNodeForCache); } @@ -700,6 +714,7 @@ export function makeEnviroNodeID(buildDirectory: string): string { } let vcastHasCodedTestsList: string[] = []; +let testNodesWithRequirements: string[] = []; // Global cache for workspace-wide env data // Used to avoid redundant API calls during refresh diff --git a/src/vcastTestInterface.ts b/src/vcastTestInterface.ts index bf107ad5..6b966832 100644 --- a/src/vcastTestInterface.ts +++ b/src/vcastTestInterface.ts @@ -149,6 +149,7 @@ export interface testDataType { resultFilePath: string; stdout: string; notes: string; + requirements: any; compoundOnly: boolean; testFile: string; testStartLine: number; @@ -192,6 +193,7 @@ interface coverageDataType { covered: number[]; uncovered: number[]; partiallyCovered: number[]; + perTestCoverage: Record>; // "lineNum" -> {"unit.func.test": "covered"|"partiallyCovered", ...} } interface fileCoverageType { @@ -278,6 +280,83 @@ export function getCoverageDataForFile(filePath: string): coverageSummaryType { return returnData; } +////////////////////////////////////////////////////////////////////// +export function getCoverageDataForFileAndTest( + filePath: string, + testId: string +): coverageSummaryType { + // Returns coverage data for a specific test case on a specific file. + // Uses the perTestCoverage data to determine which lines the test covers + // and whether coverage is full or partial (for branch/MCDC environments). + // Lines not covered by this test are marked as uncovered. + + let returnData: coverageSummaryType = { + hasCoverageData: false, + statusString: "", + covered: [], + uncovered: [], + partiallyCovered: [], + }; + + const dataForThisFile = globalCoverageData.get(filePath); + if (!dataForThisFile || !dataForThisFile.hasCoverage) { + return returnData; + } + + const checksum: number = getChecksum(filePath); + + // Collect per-test coverage and aggregate coverable lines across all enviros + const coveredByTest = new Set(); + const partiallyCoveredByTest = new Set(); + const allCoverableLines = new Set(); + + for (const [enviroPath, enviroData] of dataForThisFile.enviroList.entries()) { + if (enviroData.crc32Checksum != checksum) { + continue; + } + + // All coverable lines from aggregate data + for (const line of enviroData.covered) allCoverableLines.add(line); + for (const line of enviroData.uncovered) allCoverableLines.add(line); + for (const line of enviroData.partiallyCovered) allCoverableLines.add(line); + + // Lines covered by this specific test + // Python produces short names like "unit.function.testname"; + // compose the full test node ID to match the VS Code test explorer format + for (const [lineStr, testStatusMap] of Object.entries( + enviroData.perTestCoverage + )) { + for (const [testName, status] of Object.entries(testStatusMap)) { + const fullTestNodeId = `vcast:${enviroPath}|${testName}`; + if (fullTestNodeId === testId) { + const lineNum = Number(lineStr); + if (status === "partiallyCovered") { + partiallyCoveredByTest.add(lineNum); + } else { + // "covered" or any other status defaults to covered + coveredByTest.add(lineNum); + } + } + } + } + } + + if (allCoverableLines.size === 0) { + returnData.statusString = "Coverage Out of Date"; + return returnData; + } + + returnData.hasCoverageData = true; + returnData.covered = [...coveredByTest]; + returnData.partiallyCovered = [...partiallyCoveredByTest]; + // Uncovered = all coverable lines minus those covered or partially covered + returnData.uncovered = [...allCoverableLines].filter( + (line) => !coveredByTest.has(line) && !partiallyCoveredByTest.has(line) + ); + + return returnData; +} + export function checksumMatchesEnvironment( filePath: string, enviroPath: string @@ -338,11 +417,15 @@ export function updateGlobalDataForFile(enviroPath: string, fileList: any[]) { .map(Number); const checksum = fileList[fileIndex].cmcChecksum; + const perTestCoverage: Record> = fileList[ + fileIndex + ].perTestCoverage || {}; let coverageData: coverageDataType = { crc32Checksum: checksum, covered: coveredList, uncovered: uncoveredList, partiallyCovered: partiallyCoveredList, + perTestCoverage: perTestCoverage, }; let fileData: fileCoverageType | undefined = diff --git a/src/vcastUtilities.ts b/src/vcastUtilities.ts index c4e8c7bc..50ff3e53 100644 --- a/src/vcastUtilities.ts +++ b/src/vcastUtilities.ts @@ -166,7 +166,7 @@ function insertIncludePath(filePath: string) { fs.writeFileSync(filePath, existingJSONasString); } -function convertTestScriptContents(scriptPath: string) { +export function convertTestScriptContents(scriptPath: string) { // Read the file let originalLines = fs.readFileSync(scriptPath).toString().split(os.EOL); let newLines: string[] = [];