From 2ff5e0dc3455932b3f49d7ec031018261d350905 Mon Sep 17 00:00:00 2001 From: Fabian Tax Date: Tue, 21 Apr 2026 14:36:52 +0200 Subject: [PATCH] [Add] --add-status-column flag for an aggregated, colour-coded outcome column MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `--add-status-column` (`-st`) flag to the spreadsheet report. When enabled, vcdg appends a `STATUS` column with one of: (empty) no test cases — uncovered, default state Passed all test cases pass — green cell Failed any test case fails — red cell Mixed some Passed + some non-Passed non-Failed — orange Inconclusive no Passed and no Failed (only Skipped/Inconclusive) — grey Backwards compatible: the flag defaults to `false`, so existing CLI invocations and downstream consumers see no schema change. The status aggregation rule is exposed as a static helper (`ReportGenerator.DeriveStatus`) so callers that build their own reports can reuse it. Aggregation tests cover all five branches; the existing `Verify_..._Generate` mock is updated to match the new four-parameter signature. Motivation: today the TESTCASES column embeds outcome as free text ("Foo - Passed\nBar - Failed"), which makes filtering "show me what's not green" awkward. A dedicated Status column with a colour fill is a single-line CLI flip that turns the report into a one-glance status dashboard. --- .../GenerateCommandHandlerTestFixture.cs | 2 +- .../Services/ReportGeneratorTestFixture.cs | 55 ++++++++ VCD-Generator/Commands/GenerateCommand.cs | 21 ++- VCD-Generator/Services/IReportGenerator.cs | 10 +- VCD-Generator/Services/ReportGenerator.cs | 121 ++++++++++++++++-- 5 files changed, 196 insertions(+), 13 deletions(-) diff --git a/VCD-Generator.Tests/Commands/GenerateCommandHandlerTestFixture.cs b/VCD-Generator.Tests/Commands/GenerateCommandHandlerTestFixture.cs index 82e23e6..af677a7 100644 --- a/VCD-Generator.Tests/Commands/GenerateCommandHandlerTestFixture.cs +++ b/VCD-Generator.Tests/Commands/GenerateCommandHandlerTestFixture.cs @@ -106,7 +106,7 @@ public async Task Verify_that_InvokeAsync_returns_0() x => x.Match(It.IsAny>(), It.IsAny>()), Times.Once); - this.reportGenerator.Verify(x => x.Generate(It.IsAny>(), It.IsAny(), ReportKind.SpreadSheet), + this.reportGenerator.Verify(x => x.Generate(It.IsAny>(), It.IsAny(), ReportKind.SpreadSheet, It.IsAny()), Times.Once); Assert.That(result, Is.EqualTo(0)); diff --git a/VCD-Generator.Tests/Services/ReportGeneratorTestFixture.cs b/VCD-Generator.Tests/Services/ReportGeneratorTestFixture.cs index 205c1c2..5e48841 100644 --- a/VCD-Generator.Tests/Services/ReportGeneratorTestFixture.cs +++ b/VCD-Generator.Tests/Services/ReportGeneratorTestFixture.cs @@ -119,5 +119,60 @@ public void Verify_that_html_report_is_not_generated_and_exception_is_thrown() Assert.That(() => this.reportGenerator.Generate(this.requirements, this.spreadsheetReportPath, ReportKind.Html), Throws.TypeOf()); } + + [Test] + public void Verify_that_spreadsheet_report_is_generated_with_status_column() + { + Assert.That(() => this.reportGenerator.Generate(this.requirements, this.spreadsheetReportPath, ReportKind.SpreadSheet, addStatusColumn: true), + Throws.Nothing); + } + + [Test] + public void Verify_that_DeriveStatus_returns_empty_for_uncovered_requirement() + { + var requirement = new Requirement { Identifier = "REQ-99" }; + + Assert.That(ReportGenerator.DeriveStatus(requirement), Is.EqualTo(string.Empty)); + } + + [Test] + public void Verify_that_DeriveStatus_returns_Passed_when_every_test_case_is_Passed() + { + var requirement = new Requirement { Identifier = "REQ-99" }; + requirement.TestCases.Add(new TestCase { FullName = "T1", Result = "Passed" }); + requirement.TestCases.Add(new TestCase { FullName = "T2", Result = "Passed" }); + + Assert.That(ReportGenerator.DeriveStatus(requirement), Is.EqualTo("Passed")); + } + + [Test] + public void Verify_that_DeriveStatus_returns_Failed_when_any_test_case_is_Failed() + { + var requirement = new Requirement { Identifier = "REQ-99" }; + requirement.TestCases.Add(new TestCase { FullName = "T1", Result = "Passed" }); + requirement.TestCases.Add(new TestCase { FullName = "T2", Result = "Failed" }); + + Assert.That(ReportGenerator.DeriveStatus(requirement), Is.EqualTo("Failed")); + } + + [Test] + public void Verify_that_DeriveStatus_returns_Mixed_when_Passed_mixes_with_non_Passed_non_Failed() + { + var requirement = new Requirement { Identifier = "REQ-99" }; + requirement.TestCases.Add(new TestCase { FullName = "T1", Result = "Passed" }); + requirement.TestCases.Add(new TestCase { FullName = "T2", Result = "Inconclusive" }); + + Assert.That(ReportGenerator.DeriveStatus(requirement), Is.EqualTo("Mixed")); + } + + [Test] + public void Verify_that_DeriveStatus_returns_Inconclusive_when_no_Passed_and_no_Failed() + { + var requirement = new Requirement { Identifier = "REQ-99" }; + requirement.TestCases.Add(new TestCase { FullName = "T1", Result = "Inconclusive" }); + requirement.TestCases.Add(new TestCase { FullName = "T2", Result = "Skipped" }); + + Assert.That(ReportGenerator.DeriveStatus(requirement), Is.EqualTo("Inconclusive")); + } } } diff --git a/VCD-Generator/Commands/GenerateCommand.cs b/VCD-Generator/Commands/GenerateCommand.cs index e5aa9b1..fd4ae4f 100644 --- a/VCD-Generator/Commands/GenerateCommand.cs +++ b/VCD-Generator/Commands/GenerateCommand.cs @@ -75,6 +75,12 @@ public class GenerateCommand : RootCommand /// public Option OutputReportOption { get; } + /// + /// The that controls whether an aggregated, colour-coded + /// STATUS column is appended to the report. + /// + public Option AddStatusColumnOption { get; } + /// /// Initializes a new instance of the /// @@ -127,6 +133,12 @@ public GenerateCommand() : base("VCD Generator") Required = true, }; this.Options.Add(this.OutputReportOption); + + this.AddStatusColumnOption = new Option("--add-status-column", "-st") + { + Description = "When set, appends a STATUS column to the report with an aggregated, colour-coded per-requirement outcome (empty = uncovered, Passed = green, Failed = red, Mixed = orange, Inconclusive = grey). Default: false.", + }; + this.Options.Add(this.AddStatusColumnOption); } /// @@ -147,6 +159,7 @@ public void BindTo(Handler handler, ParseResult parseResult) handler.RequirementsTextColumn = parseResult.GetValue(this.RequirementsTextColumnOption); handler.SourceDirectory = parseResult.GetValue(this.SourceDirectoryOption); handler.OutputReport = parseResult.GetValue(this.OutputReportOption); + handler.AddStatusColumn = parseResult.GetValue(this.AddStatusColumnOption); } /// @@ -246,6 +259,12 @@ public Handler(IRequirementsReader requirementsReader, ITestResultReader resultR /// public FileInfo OutputReport { get; set; } + /// + /// Gets or sets a value indicating whether an aggregated, colour-coded STATUS + /// column is appended to the report. + /// + public bool AddStatusColumn { get; set; } + /// /// Asynchronously executes the command /// @@ -331,7 +350,7 @@ await AnsiConsole.Status() ctx.Status($"Generating report at Warp 11, Captain..., SLOW DOWN!"); Thread.Sleep(1500); - this.reportGenerator.Generate(requirements, this.OutputReport.FullName, ReportKind.SpreadSheet); + this.reportGenerator.Generate(requirements, this.OutputReport.FullName, ReportKind.SpreadSheet, this.AddStatusColumn); AnsiConsole.MarkupLine($"[grey]LOG:[/] VCD report generated at [bold]{this.OutputReport.FullName}[/]"); return Task.FromResult(0); diff --git a/VCD-Generator/Services/IReportGenerator.cs b/VCD-Generator/Services/IReportGenerator.cs index 9fc5a39..465cb20 100644 --- a/VCD-Generator/Services/IReportGenerator.cs +++ b/VCD-Generator/Services/IReportGenerator.cs @@ -35,11 +35,17 @@ public interface IReportGenerator /// The objects on the basis of which the report will be generated /// /// - /// the file path(including file-name) where the report will be generated + /// the file path(including file-name) where the report will be generated /// /// /// The kind of report that is generated /// - void Generate(IEnumerable requirements, string filePath, ReportKind reportKind); + /// + /// When true, an additional STATUS column is written to the report with an + /// aggregated per-requirement outcome (empty / Passed / Failed / Mixed / Inconclusive) and + /// a matching background colour on the cell. Defaults to false to preserve the + /// pre-existing report schema. + /// + void Generate(IEnumerable requirements, string filePath, ReportKind reportKind, bool addStatusColumn = false); } } diff --git a/VCD-Generator/Services/ReportGenerator.cs b/VCD-Generator/Services/ReportGenerator.cs index 85beeee..1855d8d 100644 --- a/VCD-Generator/Services/ReportGenerator.cs +++ b/VCD-Generator/Services/ReportGenerator.cs @@ -23,6 +23,7 @@ namespace VCD.Generator.Services using System; using System.Collections.Generic; using System.Data; + using System.Linq; using System.Text; using ClosedXML.Excel; @@ -61,17 +62,21 @@ public ReportGenerator(ILoggerFactory loggerFactory = null) /// The objects on the basis of which the report will be generated /// /// - /// the file path (including file-name) where the report will be generated + /// the file path (including file-name) where the report will be generated /// /// /// The kind of report that is generated /// - public void Generate(IEnumerable requirements, string filePath, ReportKind reportKind) + /// + /// When true, a STATUS column with an aggregated, colour-coded outcome per + /// requirement is appended to the report. See for the rules. + /// + public void Generate(IEnumerable requirements, string filePath, ReportKind reportKind, bool addStatusColumn = false) { switch (reportKind) { case ReportKind.SpreadSheet: - this.GenerateSpreadsheetReport(requirements,filePath); + this.GenerateSpreadsheetReport(requirements, filePath, addStatusColumn); break; case ReportKind.Html: this.GeneratedHtmlReport(requirements, filePath); @@ -86,29 +91,39 @@ public void Generate(IEnumerable requirements, string filePath, Rep /// The objects on the basis of which the report will be generated /// /// - /// the file path (including file-name) where the report will be generated + /// the file path (including file-name) where the report will be generated + /// + /// + /// When true, appends a STATUS column with the colour-coded per-requirement + /// outcome; see . /// - private void GenerateSpreadsheetReport(IEnumerable requirements, string filePath) + private void GenerateSpreadsheetReport(IEnumerable requirements, string filePath, bool addStatusColumn) { this.logger.LogInformation("Creating target workbook"); var wb = new XLWorkbook(); var now = DateTime.UtcNow.ToString("yyyy-MM-dd"); - + var worksheet = wb.Worksheets.Add($"VCD-{now}"); var dataTable = new DataTable(); dataTable.Columns.Add("REQUIREMENT-ID", typeof(string)); dataTable.Columns.Add("REQUIREMENT-TEXT", typeof(string)); dataTable.Columns.Add("TESTCASES", typeof(string)); - - foreach (var requirement in requirements) + if (addStatusColumn) + { + dataTable.Columns.Add("STATUS", typeof(string)); + } + + var materialisedRequirements = requirements as IList ?? requirements.ToList(); + + foreach (var requirement in materialisedRequirements) { var dataRow = dataTable.NewRow(); dataRow["REQUIREMENT-ID"] = requirement.Identifier; dataRow["REQUIREMENT-TEXT"] = requirement.Text; - + var sb = new StringBuilder(); foreach (var tc in requirement.TestCases) @@ -118,6 +133,11 @@ private void GenerateSpreadsheetReport(IEnumerable requirements, st dataRow["TESTCASES"] = sb.ToString(); + if (addStatusColumn) + { + dataRow["STATUS"] = DeriveStatus(requirement); + } + dataTable.Rows.Add(dataRow); } @@ -130,6 +150,19 @@ private void GenerateSpreadsheetReport(IEnumerable requirements, st worksheet.Column("A").Width = 25; worksheet.Column("B").Width = 80; + if (addStatusColumn) + { + const int statusColumnIndex = 4; + worksheet.Column(statusColumnIndex).Style.Alignment.WrapText = false; + worksheet.Column(statusColumnIndex).Width = 15; + + for (var i = 0; i < materialisedRequirements.Count; i++) + { + var status = (string)dataTable.Rows[i]["STATUS"]; + ApplyStatusFill(worksheet.Cell(i + 2, statusColumnIndex), status); + } + } + try { worksheet.Rows().AdjustToContents(); @@ -144,6 +177,76 @@ private void GenerateSpreadsheetReport(IEnumerable requirements, st this.logger.LogInformation("Target workbook saved to: {filePath}", filePath); } + /// + /// Aggregates the outcome of every linked to a requirement into a + /// single status value suitable for filtering. + /// + /// + /// The whose test cases are aggregated. + /// + /// + /// + /// "" — no test cases (the requirement is uncovered) + /// "Failed" — any test case has Result == "Failed" + /// "Passed" — every test case has Result == "Passed" + /// "Mixed" — at least one Passed and at least one non-Passed non-Failed + /// "Inconclusive" — no Passed and no Failed (e.g. only Skipped/Inconclusive) + /// + /// + public static string DeriveStatus(Requirement requirement) + { + if (requirement?.TestCases == null || requirement.TestCases.Count == 0) + { + return string.Empty; + } + + if (requirement.TestCases.Any(tc => tc.Result == "Failed")) + { + return "Failed"; + } + + if (requirement.TestCases.All(tc => tc.Result == "Passed")) + { + return "Passed"; + } + + if (requirement.TestCases.Any(tc => tc.Result == "Passed")) + { + return "Mixed"; + } + + return "Inconclusive"; + } + + /// + /// Applies the conventional Excel "Good/Bad/Neutral" fill palette to a status cell so the + /// report is scannable at a glance. + /// + /// + /// The to colour. + /// + /// + /// The aggregated status value produced by . + /// + private static void ApplyStatusFill(IXLCell cell, string status) + { + switch (status) + { + case "Passed": + cell.Style.Fill.BackgroundColor = XLColor.FromHtml("#C6EFCE"); // light green + break; + case "Failed": + cell.Style.Fill.BackgroundColor = XLColor.FromHtml("#FFC7CE"); // light red + break; + case "Mixed": + cell.Style.Fill.BackgroundColor = XLColor.FromHtml("#FFEB9C"); // light orange + break; + case "Inconclusive": + cell.Style.Fill.BackgroundColor = XLColor.FromHtml("#D9D9D9"); // light grey + break; + } + } + /// /// Generates a HTML Report ///