diff --git a/pom.xml b/pom.xml
index 3e48a69e..d78af34a 100644
--- a/pom.xml
+++ b/pom.xml
@@ -116,6 +116,16 @@
org.jenkins-ci.plugins
jackson2-api
+
+ org.apache.poi
+ poi
+ 5.4.1
+
+
+ org.apache.poi
+ poi-ooxml
+ 5.4.1
+
@@ -164,6 +174,25 @@
org.jenkins-ci.tools
maven-hpi-plugin
+
+ org.jacoco
+ jacoco-maven-plugin
+
+
+ prepare-agent
+
+ prepare-agent
+
+
+
+ generate-report
+ test
+
+ report
+
+
+
+
diff --git a/src/.DS_Store b/src/.DS_Store
deleted file mode 100644
index 91a3d37c..00000000
Binary files a/src/.DS_Store and /dev/null differ
diff --git a/src/main/.DS_Store b/src/main/.DS_Store
deleted file mode 100644
index 79de900f..00000000
Binary files a/src/main/.DS_Store and /dev/null differ
diff --git a/src/main/java/io/jenkins/plugins/reporter/model/Item.java b/src/main/java/io/jenkins/plugins/reporter/model/Item.java
index d5ebe13d..ff03a542 100644
--- a/src/main/java/io/jenkins/plugins/reporter/model/Item.java
+++ b/src/main/java/io/jenkins/plugins/reporter/model/Item.java
@@ -9,6 +9,7 @@
import java.io.Serializable;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
+import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -72,8 +73,11 @@ public LinkedHashMap getResult() {
return result;
}
- return getItems()
- .stream()
+ if (items == null || items.isEmpty()) {
+ return new LinkedHashMap<>();
+ }
+
+ return items.stream()
.map(Item::getResult)
.flatMap(map -> map.entrySet().stream())
.collect(Collectors.groupingBy(Map.Entry::getKey, LinkedHashMap::new, Collectors.summingInt(Map.Entry::getValue)));
@@ -102,11 +106,14 @@ public void setResult(LinkedHashMap result) {
}
public List- getItems() {
+ if (items == null) {
+ items = new ArrayList<>();
+ }
return items;
}
public boolean hasItems() {
- return !Objects.isNull(items) && !items.isEmpty();
+ return items != null && !items.isEmpty();
}
public void setItems(List
- items) {
@@ -114,6 +121,9 @@ public void setItems(List
- items) {
}
public void addItem(Item item) {
- this.items.add(item);
+ if (items == null) {
+ items = new ArrayList<>();
+ }
+ items.add(item);
}
}
\ No newline at end of file
diff --git a/src/main/java/io/jenkins/plugins/reporter/provider/AbstractExcelProvider.java b/src/main/java/io/jenkins/plugins/reporter/provider/AbstractExcelProvider.java
new file mode 100644
index 00000000..638fb0da
--- /dev/null
+++ b/src/main/java/io/jenkins/plugins/reporter/provider/AbstractExcelProvider.java
@@ -0,0 +1,246 @@
+package io.jenkins.plugins.reporter.provider;
+
+import io.jenkins.plugins.reporter.model.Provider;
+import io.jenkins.plugins.reporter.model.ReportDto;
+import io.jenkins.plugins.reporter.model.ReportParser;
+import io.jenkins.plugins.reporter.util.TabularData;
+import org.apache.poi.ss.usermodel.*;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Abstract base class for Excel-based providers
+ * Contains common functionality for Excel file parsing
+ */
+public abstract class AbstractExcelProvider extends Provider {
+
+ private static final long serialVersionUID = 5463781055792899347L;
+
+ /** Default constructor */
+ protected AbstractExcelProvider() {
+ super();
+ }
+
+ /**
+ * Base class for Excel parsers
+ */
+ public abstract static class AbstractExcelParser extends ReportParser {
+
+ private static final long serialVersionUID = -8689695008930386641L;
+ protected final String id;
+ protected List parserMessages;
+
+ /** Constructor */
+ protected AbstractExcelParser(String id) {
+ super();
+ this.id = id;
+ this.parserMessages = new ArrayList<>();
+ }
+
+ /** Returns the parser identifier */
+ public String getId() {
+ return id;
+ }
+
+ /** Detects the table position in an Excel sheet */
+ protected TablePosition detectTablePosition(Sheet sheet) {
+ int startRow = -1;
+ int startCol = -1;
+ int maxNonEmptyConsecutiveCells = 0;
+ int headerRowIndex = -1;
+
+ // Check the first 20 rows to find the table start
+ int maxRowsToCheck = Math.min(20, sheet.getLastRowNum() + 1);
+
+ for (int rowIndex = 0; rowIndex < maxRowsToCheck; rowIndex++) {
+ Row row = sheet.getRow(rowIndex);
+ if (row == null) continue;
+
+ int nonEmptyConsecutiveCells = 0;
+ int firstNonEmptyCellIndex = -1;
+
+ // Check the cells in the row
+ for (int colIndex = 0; colIndex < 100; colIndex++) { // Arbitrary limit of 100 columns
+ Cell cell = row.getCell(colIndex, Row.MissingCellPolicy.RETURN_BLANK_AS_NULL);
+ if (cell != null) {
+ if (firstNonEmptyCellIndex == -1) {
+ firstNonEmptyCellIndex = colIndex;
+ }
+ nonEmptyConsecutiveCells++;
+ } else if (firstNonEmptyCellIndex != -1) {
+ // Found an empty cell after non-empty cells
+ break;
+ }
+ }
+
+ // If we found a row with more consecutive non-empty cells than before
+ if (nonEmptyConsecutiveCells > maxNonEmptyConsecutiveCells && nonEmptyConsecutiveCells >= 2) {
+ maxNonEmptyConsecutiveCells = nonEmptyConsecutiveCells;
+ headerRowIndex = rowIndex;
+ startCol = firstNonEmptyCellIndex;
+ }
+ }
+
+ // If we found a potential header
+ if (headerRowIndex != -1) {
+ startRow = headerRowIndex;
+ } else {
+ // Default to the first row
+ startRow = 0;
+ startCol = 0;
+ }
+
+ return new TablePosition(startRow, startCol);
+ }
+
+ /** Checks if a row is empty */
+ protected boolean isRowEmpty(Row row, int startCol, int columnCount) {
+ if (row == null) return true;
+
+ for (int i = startCol; i < startCol + columnCount; i++) {
+ Cell cell = row.getCell(i, Row.MissingCellPolicy.RETURN_BLANK_AS_NULL);
+ if (cell != null && !getCellValueAsString(cell).trim().isEmpty()) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ /** Extracts data from an Excel sheet */
+ protected TabularData extractSheetData(Sheet sheet, List referenceHeader) {
+ // Detect table position
+ TablePosition tablePos = detectTablePosition(sheet);
+ int startRow = tablePos.getStartRow();
+ int startCol = tablePos.getStartCol();
+
+ // Get the header row
+ Row headerRow = sheet.getRow(startRow);
+ if (headerRow == null) {
+ parserMessages.add(String.format("Skipped sheet '%s' - No header row found", sheet.getSheetName()));
+ return null;
+ }
+
+ // Extract headers
+ List header = new ArrayList<>();
+ int lastCol = headerRow.getLastCellNum();
+ for (int colIdx = startCol; colIdx < lastCol; colIdx++) {
+ Cell cell = headerRow.getCell(colIdx, Row.MissingCellPolicy.RETURN_BLANK_AS_NULL);
+ if (cell != null) {
+ header.add(getCellValueAsString(cell));
+ } else {
+ // If we find an empty cell in the header, stop
+ break;
+ }
+ }
+
+ // Check that the header has at least 2 columns
+ if (header.size() < 2) {
+ parserMessages.add(String.format("Skipped sheet '%s' - Header has less than 2 columns", sheet.getSheetName()));
+ return null;
+ }
+
+ // If a reference header is provided, check that it matches
+ if (referenceHeader != null && !header.equals(referenceHeader)) {
+ parserMessages.add(String.format("Skipped sheet '%s' - Header does not match reference header", sheet.getSheetName()));
+ return null;
+ }
+
+ // Extract data rows
+ List
> rows = new ArrayList<>();
+ int headerColumnCount = header.size();
+
+ for (int rowIdx = startRow + 1; rowIdx <= sheet.getLastRowNum(); rowIdx++) {
+ Row dataRow = sheet.getRow(rowIdx);
+
+ // Skip if row is null
+ if (dataRow == null) {
+ continue;
+ }
+
+ List rowData = new ArrayList<>();
+ boolean hasData = false;
+
+ // Extract data from the row
+ for (int colIdx = startCol; colIdx < startCol + headerColumnCount; colIdx++) {
+ Cell cell = dataRow.getCell(colIdx, Row.MissingCellPolicy.RETURN_BLANK_AS_NULL);
+ String value = getCellValueAsString(cell);
+ rowData.add(value);
+ if (cell != null && !value.trim().isEmpty()) {
+ hasData = true;
+ }
+ }
+
+ // Only add rows that have at least one non-empty cell
+ if (hasData) {
+ rows.add(rowData);
+ }
+ }
+
+ // Only return data if we found any rows
+ return rows.isEmpty() ? null : new TabularData(id, header, rows);
+ }
+
+ /** Converts an Excel cell value to a string */
+ protected String getCellValueAsString(Cell cell) {
+ if (cell == null) {
+ return "";
+ }
+
+ switch (cell.getCellType()) {
+ case STRING:
+ return cell.getStringCellValue();
+ case NUMERIC:
+ if (DateUtil.isCellDateFormatted(cell)) {
+ return cell.getDateCellValue().toString();
+ } else {
+ // To avoid scientific notation display
+ double value = cell.getNumericCellValue();
+ if (value == Math.floor(value)) {
+ return String.format("%.0f", value);
+ } else {
+ return String.valueOf(value);
+ }
+ }
+ case BOOLEAN:
+ return String.valueOf(cell.getBooleanCellValue());
+ case FORMULA:
+ try {
+ return String.valueOf(cell.getNumericCellValue());
+ } catch (IllegalStateException e) {
+ try {
+ return String.valueOf(cell.getStringCellValue());
+ } catch (IllegalStateException e2) {
+ return "#ERROR";
+ }
+ }
+ default:
+ return "";
+ }
+ }
+
+ /** Internal class to store the position of a table in an Excel sheet */
+ protected static class TablePosition {
+ private final int startRow;
+ private final int startCol;
+
+ public TablePosition(int startRow, int startCol) {
+ this.startRow = startRow;
+ this.startCol = startCol;
+ }
+
+ public int getStartRow() {
+ return startRow;
+ }
+
+ public int getStartCol() {
+ return startCol;
+ }
+ }
+ }
+}
+
diff --git a/src/main/java/io/jenkins/plugins/reporter/provider/Csv.java b/src/main/java/io/jenkins/plugins/reporter/provider/Csv.java
index 4417152d..272f6742 100644
--- a/src/main/java/io/jenkins/plugins/reporter/provider/Csv.java
+++ b/src/main/java/io/jenkins/plugins/reporter/provider/Csv.java
@@ -7,12 +7,11 @@
import hudson.Extension;
import io.jenkins.plugins.reporter.Messages;
-import io.jenkins.plugins.reporter.model.Item;
import io.jenkins.plugins.reporter.model.Provider;
import io.jenkins.plugins.reporter.model.ReportDto;
import io.jenkins.plugins.reporter.model.ReportParser;
+import io.jenkins.plugins.reporter.util.TabularData;
import org.apache.commons.lang3.StringUtils;
-import org.apache.commons.lang3.math.NumberUtils;
import org.jenkinsci.Symbol;
import org.kohsuke.stapler.DataBoundConstructor;
@@ -24,18 +23,23 @@
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
+/**
+ * Provider for CSV files
+ */
public class Csv extends Provider {
private static final long serialVersionUID = 9141170397250309265L;
private static final String ID = "csv";
+ /** Default constructor */
@DataBoundConstructor
public Csv() {
super();
// empty constructor required for stapler
}
+ /** Creates a CSV parser */
@Override
public ReportParser createParser() {
if (getActualId().equals(getDescriptor().getId())) {
@@ -45,41 +49,45 @@ public ReportParser createParser() {
return new CsvCustomParser(getActualId());
}
- /** Descriptor for this provider. */
+ /** Descriptor for this provider */
@Symbol("csv")
@Extension
- public static class Descriptor extends Provider.ProviderDescriptor {
- /** Creates the descriptor instance. */
+ public static class Descriptor extends ProviderDescriptor {
+ /** Creates a descriptor instance */
public Descriptor() {
super(ID);
}
}
+ /**
+ * Parser for CSV files
+ */
public static class CsvCustomParser extends ReportParser {
private static final long serialVersionUID = -8689695008930386640L;
private final String id;
-
private List parserMessages;
+ /** Constructor */
public CsvCustomParser(String id) {
super();
this.id = id;
this.parserMessages = new ArrayList();
}
+ /** Returns the parser identifier */
public String getId() {
return id;
}
-
+ /** Detects the delimiter used in the CSV file */
private char detectDelimiter(File file) throws IOException {
// List of possible delimiters
char[] delimiters = { ',', ';', '\t', '|' };
int[] delimiterCounts = new int[delimiters.length];
- // Read the lines of the file to detect the delimiter
+ // Read the file lines to detect the delimiter
try (BufferedReader reader = new BufferedReader(new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8))) {
int linesToCheck = 5; // Number of lines to check
int linesChecked = 0;
@@ -105,13 +113,14 @@ private char detectDelimiter(File file) throws IOException {
return detectedDelimiter;
}
-
+ /** Parses a CSV file and creates a ReportDto */
@Override
public ReportDto parse(File file) throws IOException {
- // Get delimiter
+ // Delimiter detection
char delimiter = detectDelimiter(file);
+ // CSV parser configuration
final CsvMapper mapper = new CsvMapper();
final CsvSchema schema = mapper.schemaFor(String[].class).withColumnSeparator(delimiter);
@@ -121,139 +130,18 @@ public ReportDto parse(File file) throws IOException {
mapper.enable(CsvParser.Feature.INSERT_NULLS_FOR_MISSING_COLUMNS);
mapper.enable(CsvParser.Feature.TRIM_SPACES);
+ // Read CSV data
final MappingIterator> it = mapper.readerForListOf(String.class)
.with(schema)
.readValues(file);
- ReportDto report = new ReportDto();
- report.setId(getId());
- report.setItems(new ArrayList<>());
-
+ // Extract headers and rows
final List header = it.next();
final List> rows = it.readAll();
- int rowCount = 0;
- final int headerColumnCount = header.size();
- int colIdxValueStart = 0;
-
- if (headerColumnCount >= 2) {
- rowCount = rows.size();
- } else {
- parserMessages.add(String.format("skipped file - First line has %d elements", headerColumnCount + 1));
- }
-
- /** Parse all data rows */
- for (int rowIdx = 0; rowIdx < rowCount; rowIdx++) {
- String parentId = "report";
- List row = rows.get(rowIdx);
- Item last = null;
- boolean lastItemAdded = false;
- LinkedHashMap result = new LinkedHashMap<>();
- boolean emptyFieldFound = false;
- int rowSize = row.size();
-
- /** Parse untill first data line is found to get data and value field */
- if (colIdxValueStart == 0) {
- /** Col 0 is assumed to be string */
- for (int colIdx = rowSize - 1; colIdx > 1; colIdx--) {
- String value = row.get(colIdx);
-
- if (NumberUtils.isCreatable(value)) {
- colIdxValueStart = colIdx;
- } else {
- if (colIdxValueStart > 0) {
- parserMessages
- .add(String.format("Found data - fields number = %d - numeric fields = %d",
- colIdxValueStart, rowSize - colIdxValueStart));
- }
- break;
- }
- }
- }
-
- String valueId = "";
- /** Parse line if first data line is OK and line has more element than header */
- if ((colIdxValueStart > 0) && (rowSize >= headerColumnCount)) {
- /** Check line and header size matching */
- for (int colIdx = 0; colIdx < headerColumnCount; colIdx++) {
- String id = header.get(colIdx);
- String value = row.get(colIdx);
-
- /** Check value fields */
- if ((colIdx < colIdxValueStart)) {
- /** Test if text item is a value or empty */
- if ((NumberUtils.isCreatable(value)) || (StringUtils.isBlank(value))) {
- /** Empty field found - message */
- if (colIdx == 0) {
- parserMessages
- .add(String.format("skipped line %d - First column item empty - col = %d ",
- rowIdx + 2, colIdx + 1));
- break;
- } else {
- emptyFieldFound = true;
- /** Continue next column parsing */
- continue;
- }
- } else {
- /** Check if field values are present after empty cells */
- if (emptyFieldFound) {
- parserMessages.add(String.format("skipped line %d Empty field in col = %d ",
- rowIdx + 2, colIdx + 1));
- break;
- }
- }
- valueId += value;
- Optional- parent = report.findItem(parentId, report.getItems());
- Item item = new Item();
- lastItemAdded = false;
- item.setId(valueId);
- item.setName(value);
- String finalValueId = valueId;
- if (parent.isPresent()) {
- Item p = parent.get();
- if (!p.hasItems()) {
- p.setItems(new ArrayList<>());
- }
- if (p.getItems().stream().noneMatch(i -> i.getId().equals(finalValueId))) {
- p.addItem(item);
- lastItemAdded = true;
- }
- } else {
- if (report.getItems().stream().noneMatch(i -> i.getId().equals(finalValueId))) {
- report.getItems().add(item);
- lastItemAdded = true;
- }
- }
- parentId = valueId;
- last = item;
- } else {
- Number val = 0;
- if (NumberUtils.isCreatable(value)) {
- val = NumberUtils.createNumber(value);
- }
- result.put(id, val.intValue());
- }
- }
- } else {
- /** Skip file if first data line has no value field */
- if (colIdxValueStart == 0) {
- parserMessages.add(String.format("skipped line %d - First data row not found", rowIdx + 2));
- continue;
- } else {
- parserMessages
- .add(String.format("skipped line %d - line has fewer element than title", rowIdx + 2));
- continue;
- }
- }
- /** If last item was created, it will be added to report */
- if (lastItemAdded) {
- last.setResult(result);
- } else {
- parserMessages.add(String.format("ignored line %d - Same fields already exists", rowIdx + 2));
- }
- }
- // report.setParserLog(parserMessages);
- return report;
+ // Create TabularData object and process it
+ TabularData tabularData = new TabularData(id, header, rows);
+ return tabularData.processData(parserMessages);
}
}
-}
\ No newline at end of file
+}
diff --git a/src/main/java/io/jenkins/plugins/reporter/provider/Excel.java b/src/main/java/io/jenkins/plugins/reporter/provider/Excel.java
new file mode 100644
index 00000000..ed86c667
--- /dev/null
+++ b/src/main/java/io/jenkins/plugins/reporter/provider/Excel.java
@@ -0,0 +1,105 @@
+package io.jenkins.plugins.reporter.provider;
+
+import hudson.Extension;
+import io.jenkins.plugins.reporter.Messages;
+import io.jenkins.plugins.reporter.model.ReportDto;
+import io.jenkins.plugins.reporter.model.ReportParser;
+import io.jenkins.plugins.reporter.util.TabularData;
+import org.apache.poi.ss.usermodel.*;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+import org.jenkinsci.Symbol;
+import org.kohsuke.stapler.DataBoundConstructor;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.util.List;
+
+/**
+ * Provider for Excel (XLSX) files
+ */
+public class Excel extends AbstractExcelProvider {
+
+ private static final long serialVersionUID = 9141170397250309265L;
+
+ private static final String ID = "excel";
+
+ /** Default constructor */
+ @DataBoundConstructor
+ public Excel() {
+ super();
+ // empty constructor required for stapler
+ }
+
+ /** Creates an Excel parser */
+ @Override
+ public ReportParser createParser() {
+ if (getActualId().equals(getDescriptor().getId())) {
+ throw new IllegalArgumentException(Messages.Provider_Error());
+ }
+
+ return new ExcelParser(getActualId());
+ }
+
+ /** Descriptor for this provider */
+ @Symbol("excel")
+ @Extension
+ public static class Descriptor extends ProviderDescriptor {
+ /** Creates a descriptor instance */
+ public Descriptor() {
+ super(ID);
+ }
+ }
+
+ /**
+ * Parser for Excel files
+ */
+ public static class ExcelParser extends AbstractExcelParser {
+
+ private static final long serialVersionUID = -8689695008930386641L;
+
+ /** Constructor */
+ public ExcelParser(String id) {
+ super(id);
+ }
+
+ /** Parses an Excel file and creates a ReportDto */
+ @Override
+ public ReportDto parse(File file) throws IOException {
+ try (FileInputStream fis = new FileInputStream(file);
+ Workbook workbook = new XSSFWorkbook(fis)) {
+
+ TabularData result = null;
+ List referenceHeader = null;
+
+ // Process all sheets in the workbook
+ for (int i = 0; i < workbook.getNumberOfSheets(); i++) {
+ Sheet sheet = workbook.getSheetAt(i);
+
+ // Extract data from the sheet
+ TabularData sheetData = extractSheetData(sheet, referenceHeader);
+
+ // If the sheet contains valid data
+ if (sheetData != null) {
+ // If it's the first valid sheet, use it as reference
+ if (result == null) {
+ result = sheetData;
+ referenceHeader = sheetData.getHeader();
+ } else {
+ // Otherwise, add the sheet rows to the result
+ result.getRows().addAll(sheetData.getRows());
+ }
+ }
+ }
+
+ // If no sheet contains valid data
+ if (result == null) {
+ throw new IOException("No valid data found in Excel file");
+ }
+
+ // Process tabular data
+ return result.processData(parserMessages);
+ }
+ }
+ }
+}
diff --git a/src/main/java/io/jenkins/plugins/reporter/provider/ExcelMulti.java b/src/main/java/io/jenkins/plugins/reporter/provider/ExcelMulti.java
new file mode 100644
index 00000000..88b65edd
--- /dev/null
+++ b/src/main/java/io/jenkins/plugins/reporter/provider/ExcelMulti.java
@@ -0,0 +1,150 @@
+package io.jenkins.plugins.reporter.provider;
+
+import hudson.Extension;
+import io.jenkins.plugins.reporter.Messages;
+import io.jenkins.plugins.reporter.model.ReportDto;
+import io.jenkins.plugins.reporter.model.ReportParser;
+import io.jenkins.plugins.reporter.util.TabularData;
+import org.apache.poi.ss.usermodel.*;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+import org.jenkinsci.Symbol;
+import org.kohsuke.stapler.DataBoundConstructor;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Provider for multi-sheet Excel (XLSX) files with header consistency enforcement
+ * This provider parses all sheets in an Excel file, enforcing header consistency across sheets.
+ * Only sheets with identical headers (compared to the first valid sheet) will be processed.
+ */
+public class ExcelMulti extends AbstractExcelProvider {
+
+ private static final long serialVersionUID = 1845129735392309267L;
+
+ private static final String ID = "excelMulti";
+
+ /** Default constructor */
+ @DataBoundConstructor
+ public ExcelMulti() {
+ super();
+ // empty constructor required for stapler
+ }
+
+ /** Creates an ExcelMulti parser */
+ @Override
+ public ReportParser createParser() {
+ if (getActualId().equals(getDescriptor().getId())) {
+ throw new IllegalArgumentException(Messages.Provider_Error());
+ }
+
+ return new ExcelMultiParser(getActualId());
+ }
+
+ /** Descriptor for this provider */
+ @Symbol("excelMulti")
+ @Extension
+ public static class Descriptor extends ProviderDescriptor {
+ /** Creates a descriptor instance */
+ public Descriptor() {
+ super(ID);
+ }
+ }
+
+ /**
+ * Parser for multi-sheet Excel files with header consistency enforcement
+ */
+ public static class ExcelMultiParser extends AbstractExcelParser {
+
+ private static final long serialVersionUID = 3724789235028726491L;
+
+ /** Constructor */
+ public ExcelMultiParser(String id) {
+ super(id);
+ }
+
+ /**
+ * Parses an Excel file with multiple sheets and creates a ReportDto
+ * Only processes sheets with consistent headers (identical to the first valid sheet)
+ */
+ @Override
+ public ReportDto parse(File file) throws IOException {
+ try (FileInputStream fis = new FileInputStream(file);
+ Workbook workbook = new XSSFWorkbook(fis)) {
+
+ TabularData result = null;
+ List referenceHeader = null;
+ int totalSheets = workbook.getNumberOfSheets();
+ int processedSheets = 0;
+ int skippedSheets = 0;
+ List processedSheetNames = new ArrayList<>();
+ List skippedSheetNames = new ArrayList<>();
+ List
> allRows = new ArrayList<>();
+
+ // Add an initial parser message about number of sheets
+ parserMessages.add(String.format("Excel file contains %d sheets", totalSheets));
+
+ // First pass: validate sheets and collect all rows
+ for (int i = 0; i < totalSheets; i++) {
+ Sheet sheet = workbook.getSheetAt(i);
+ String sheetName = sheet.getSheetName();
+
+ // Extract data from the sheet
+ TabularData sheetData = extractSheetData(sheet, referenceHeader);
+
+ // If the sheet contains valid data
+ if (sheetData != null) {
+ // If it's the first valid sheet, use it as reference
+ if (referenceHeader == null) {
+ referenceHeader = sheetData.getHeader();
+ allRows.addAll(sheetData.getRows());
+ processedSheets++;
+ processedSheetNames.add(sheetName);
+ parserMessages.add(String.format("Processing sheet '%s' as reference sheet with %d columns: %s",
+ sheetName, referenceHeader.size(), String.join(", ", referenceHeader)));
+ } else {
+ // For subsequent sheets, add rows to the collection
+ allRows.addAll(sheetData.getRows());
+ processedSheets++;
+ processedSheetNames.add(sheetName);
+ parserMessages.add(String.format("Processing sheet '%s' - adding %d rows",
+ sheetName, sheetData.getRows().size()));
+ }
+ } else {
+ skippedSheets++;
+ skippedSheetNames.add(sheetName);
+ parserMessages.add(String.format("Skipped sheet '%s' - Invalid format or no data", sheetName));
+ }
+ }
+
+ // Create final TabularData with all rows
+ if (referenceHeader != null && !allRows.isEmpty()) {
+ result = new TabularData(getId(), referenceHeader, allRows);
+ }
+
+ // Add summary information to parser messages
+ parserMessages.add(String.format("Processed %d sheets: %s",
+ processedSheets, String.join(", ", processedSheetNames)));
+
+ if (skippedSheets > 0) {
+ parserMessages.add(String.format("Skipped %d sheets: %s",
+ skippedSheets, String.join(", ", skippedSheetNames)));
+ }
+
+ // If no sheet contains valid data
+ if (result == null) {
+ throw new IOException("No valid data found in Excel file. All sheets were skipped.");
+ }
+
+ parserMessages.add(String.format("Total rows collected: %d", allRows.size()));
+
+ // Process and return the final tabular data
+ return result.processData(parserMessages);
+ }
+ }
+ }
+}
+
diff --git a/src/main/java/io/jenkins/plugins/reporter/util/TabularData.java b/src/main/java/io/jenkins/plugins/reporter/util/TabularData.java
new file mode 100644
index 00000000..f1817c21
--- /dev/null
+++ b/src/main/java/io/jenkins/plugins/reporter/util/TabularData.java
@@ -0,0 +1,192 @@
+package io.jenkins.plugins.reporter.util;
+
+import io.jenkins.plugins.reporter.model.Item;
+import io.jenkins.plugins.reporter.model.ReportDto;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.math.NumberUtils;
+
+import java.io.Serializable;
+import java.util.*;
+import java.util.stream.Collectors;
+
+/**
+ * Utility class for tabular data processing
+ * Combines data storage and processing functionality
+ */
+public class TabularData implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ private final String sourceId;
+ private final List header;
+ private final List> rows;
+
+ /** Constructor */
+ public TabularData(String sourceId, List header, List> rows) {
+ this.sourceId = sourceId;
+ this.header = header;
+ this.rows = rows;
+ }
+
+ /** Returns the list of column headers */
+ public List getHeader() {
+ return header;
+ }
+
+ /** Returns the list of data rows */
+ public List> getRows() {
+ List> rowsCopy = new ArrayList<>();
+ for (List row : rows) {
+ rowsCopy.add(new ArrayList<>(row));
+ }
+ return rowsCopy;
+ }
+
+ /**
+ * Process tabular data to create a ReportDto
+ * @param parserMessages List to store parser messages
+ * @return ReportDto containing the processed data
+ */
+ public ReportDto processData(List parserMessages) {
+ // Create the report
+ ReportDto report = new ReportDto();
+ report.setId(sourceId);
+ report.setItems(new ArrayList<>());
+
+ int rowCount = 0;
+ final int headerColumnCount = header.size();
+
+ // First two columns are always category columns, rest are value columns
+ final int categoryColumns = 2;
+ final int valueColumns = headerColumnCount - categoryColumns;
+
+ if (headerColumnCount >= 2) {
+ rowCount = rows.size();
+ parserMessages.add(String.format("Processing data with %d rows, %d category columns, %d value columns",
+ rowCount, categoryColumns, valueColumns));
+ parserMessages.add(String.format("Headers: %s", String.join(", ", header)));
+ } else {
+ parserMessages.add(String.format("skipped file - First line has %d elements", headerColumnCount));
+ return report;
+ }
+
+ /** Parse all data rows */
+ for (int rowIdx = 0; rowIdx < rowCount; rowIdx++) {
+ List row = rows.get(rowIdx);
+
+ // Add debug info for first and last row
+ if (rowIdx == 0 || rowIdx == rows.size() - 1) { // Only log first and last row for brevity
+ parserMessages.add(String.format("Processing row %d: %s",
+ rowIdx + 1,
+ String.join(", ", row.subList(0, Math.min(row.size(), 5))) +
+ (row.size() > 5 ? "..." : "")));
+ }
+
+ if (row.size() < headerColumnCount) {
+ parserMessages.add(String.format("skipped line %d - line has fewer elements than header", rowIdx + 2));
+ continue;
+ }
+
+ // Process category columns
+ String[] categories = new String[categoryColumns];
+ boolean validRow = true;
+ for (int i = 0; i < categoryColumns; i++) {
+ categories[i] = row.get(i).trim();
+ if (categories[i].isEmpty()) {
+ validRow = false;
+ break;
+ }
+ }
+
+ if (!validRow) {
+ parserMessages.add(String.format("skipped line %d - empty category", rowIdx + 2));
+ continue;
+ }
+
+ // Process each category level
+ Item currentItem = null;
+ String parentId = "report";
+
+ for (int level = 0; level < categories.length; level++) {
+ String categoryName = categories[level];
+ String categoryId = level == 0 ? categoryName : parentId + categoryName;
+
+ if (level == 0) {
+ // Find or create root level item
+ Optional- existing = report.getItems().stream()
+ .filter(i -> i.getName().equals(categoryName))
+ .findFirst();
+
+ if (existing.isPresent()) {
+ currentItem = existing.get();
+ } else {
+ currentItem = new Item();
+ currentItem.setId(categoryId);
+ currentItem.setName(categoryName);
+ report.getItems().add(currentItem);
+ parserMessages.add(String.format("Created new root item: %s", categoryName));
+ }
+ } else {
+ // Find or create sub-item
+ if (!currentItem.hasItems()) {
+ currentItem.setItems(new ArrayList<>());
+ }
+
+ Optional
- existing = currentItem.getItems().stream()
+ .filter(i -> i.getName().equals(categoryName))
+ .findFirst();
+
+ if (existing.isPresent()) {
+ currentItem = existing.get();
+ } else {
+ Item newItem = new Item();
+ newItem.setId(categoryId);
+ newItem.setName(categoryName);
+ currentItem.getItems().add(newItem);
+ currentItem = newItem;
+ parserMessages.add(String.format("Created new sub-item: %s under %s",
+ categoryName, categories[level-1]));
+ }
+ }
+ parentId = categoryId;
+ }
+
+ // Process value columns
+ if (currentItem != null) {
+ LinkedHashMap values = new LinkedHashMap<>();
+ for (int i = categoryColumns; i < headerColumnCount && i < row.size(); i++) {
+ String headerName = header.get(i);
+ String value = row.get(i);
+ int numericValue = NumberUtils.isCreatable(value) ?
+ NumberUtils.createNumber(value).intValue() : 0;
+ values.put(headerName, numericValue);
+ }
+
+ if (currentItem.getResult() == null) {
+ currentItem.setResult(values);
+ } else {
+ // Merge values with existing results
+ LinkedHashMap existing = currentItem.getResult();
+ for (Map.Entry entry : values.entrySet()) {
+ existing.merge(entry.getKey(), entry.getValue(), Integer::sum);
+ }
+ }
+
+ parserMessages.add(String.format("Processed %s: %s", currentItem.getName(),
+ values.entrySet().stream()
+ .map(e -> e.getKey() + "=" + e.getValue())
+ .collect(Collectors.joining(", "))));
+ }
+ }
+
+ // Add debug info about final report structure
+ parserMessages.add(String.format("Final report contains %d root items", report.getItems().size()));
+ for (Item item : report.getItems()) {
+ parserMessages.add(String.format("Root item: %s with %d subitems",
+ item.getName(),
+ item.hasItems() ? item.getItems().size() : 0));
+ }
+
+ return report;
+ }
+}
diff --git a/src/main/webapp/.DS_Store b/src/main/webapp/.DS_Store
deleted file mode 100644
index a9ed938a..00000000
Binary files a/src/main/webapp/.DS_Store and /dev/null differ
diff --git a/src/test/java/io/jenkins/plugins/reporter/provider/CreateExcelMultiSample.java b/src/test/java/io/jenkins/plugins/reporter/provider/CreateExcelMultiSample.java
new file mode 100644
index 00000000..32a67181
--- /dev/null
+++ b/src/test/java/io/jenkins/plugins/reporter/provider/CreateExcelMultiSample.java
@@ -0,0 +1,233 @@
+package io.jenkins.plugins.reporter.provider;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import org.apache.poi.ss.usermodel.*;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+
+/**
+ * Utility class to generate multi-tab Excel test files for testing the ExcelMulti provider functionality.
+ */
+public class CreateExcelMultiSample {
+
+ public static void main(String[] args) throws IOException {
+ // Create test files in the resources directory
+ String resourcesDir = "src/test/resources";
+
+ // Create multi-sheet Excel file with consistent headers
+ createConsistentHeadersExcelFile(new File(resourcesDir, "test-excel-multi-consistent.xlsx"));
+
+ // Create multi-sheet Excel file with inconsistent headers
+ createInconsistentHeadersExcelFile(new File(resourcesDir, "test-excel-multi-inconsistent.xlsx"));
+
+ // Create multi-sheet Excel file with mixed validity
+ createMixedValidityExcelFile(new File(resourcesDir, "test-excel-multi-mixed.xlsx"));
+
+ System.out.println("Multi-tab test Excel files created successfully in " + resourcesDir);
+ }
+
+ /**
+ * Creates a multi-sheet Excel file with consistent headers across all sheets.
+ */
+ public static void createConsistentHeadersExcelFile(File file) throws IOException {
+ Workbook workbook = new XSSFWorkbook();
+
+ // Create first sheet
+ Sheet sheet1 = workbook.createSheet("Sheet 1");
+
+ // Create header row
+ Row headerRow1 = sheet1.createRow(0);
+ headerRow1.createCell(0).setCellValue("Category");
+ headerRow1.createCell(1).setCellValue("Subcategory");
+ headerRow1.createCell(2).setCellValue("Value1");
+ headerRow1.createCell(3).setCellValue("Value2");
+
+ // Create data rows
+ Row dataRow1 = sheet1.createRow(1);
+ dataRow1.createCell(0).setCellValue("Category A");
+ dataRow1.createCell(1).setCellValue("Subcat A1");
+ dataRow1.createCell(2).setCellValue(10);
+ dataRow1.createCell(3).setCellValue(20);
+
+ Row dataRow2 = sheet1.createRow(2);
+ dataRow2.createCell(0).setCellValue("Category A");
+ dataRow2.createCell(1).setCellValue("Subcat A2");
+ dataRow2.createCell(2).setCellValue(15);
+ dataRow2.createCell(3).setCellValue(25);
+
+ // Create second sheet with the same headers
+ Sheet sheet2 = workbook.createSheet("Sheet 2");
+
+ // Create identical header row
+ Row headerRow2 = sheet2.createRow(0);
+ headerRow2.createCell(0).setCellValue("Category");
+ headerRow2.createCell(1).setCellValue("Subcategory");
+ headerRow2.createCell(2).setCellValue("Value1");
+ headerRow2.createCell(3).setCellValue("Value2");
+
+ // Create data rows with different data
+ Row dataRow3 = sheet2.createRow(1);
+ dataRow3.createCell(0).setCellValue("Category B");
+ dataRow3.createCell(1).setCellValue("Subcat B1");
+ dataRow3.createCell(2).setCellValue(30);
+ dataRow3.createCell(3).setCellValue(40);
+
+ Row dataRow4 = sheet2.createRow(2);
+ dataRow4.createCell(0).setCellValue("Category B");
+ dataRow4.createCell(1).setCellValue("Subcat B2");
+ dataRow4.createCell(2).setCellValue(35);
+ dataRow4.createCell(3).setCellValue(45);
+
+ // Create third sheet with the same headers
+ Sheet sheet3 = workbook.createSheet("Sheet 3");
+
+ // Create identical header row
+ Row headerRow3 = sheet3.createRow(0);
+ headerRow3.createCell(0).setCellValue("Category");
+ headerRow3.createCell(1).setCellValue("Subcategory");
+ headerRow3.createCell(2).setCellValue("Value1");
+ headerRow3.createCell(3).setCellValue("Value2");
+
+ // Create data rows with different data
+ Row dataRow5 = sheet3.createRow(1);
+ dataRow5.createCell(0).setCellValue("Category C");
+ dataRow5.createCell(1).setCellValue("Subcat C1");
+ dataRow5.createCell(2).setCellValue(50);
+ dataRow5.createCell(3).setCellValue(60);
+
+ // Write to file
+ try (FileOutputStream fileOut = new FileOutputStream(file)) {
+ workbook.write(fileOut);
+ }
+ workbook.close();
+ }
+
+ /**
+ * Creates a multi-sheet Excel file with inconsistent headers across sheets.
+ */
+ public static void createInconsistentHeadersExcelFile(File file) throws IOException {
+ Workbook workbook = new XSSFWorkbook();
+
+ // Create first sheet
+ Sheet sheet1 = workbook.createSheet("Sheet 1");
+
+ // Create header row
+ Row headerRow1 = sheet1.createRow(0);
+ headerRow1.createCell(0).setCellValue("Category");
+ headerRow1.createCell(1).setCellValue("Subcategory");
+ headerRow1.createCell(2).setCellValue("Value1");
+ headerRow1.createCell(3).setCellValue("Value2");
+
+ // Create data rows
+ Row dataRow1 = sheet1.createRow(1);
+ dataRow1.createCell(0).setCellValue("Category A");
+ dataRow1.createCell(1).setCellValue("Subcat A1");
+ dataRow1.createCell(2).setCellValue(10);
+ dataRow1.createCell(3).setCellValue(20);
+
+ // Create second sheet with different headers
+ Sheet sheet2 = workbook.createSheet("Sheet 2");
+
+ // Create different header row (different column order)
+ Row headerRow2 = sheet2.createRow(0);
+ headerRow2.createCell(0).setCellValue("Category");
+ headerRow2.createCell(1).setCellValue("Value1"); // Swapped order
+ headerRow2.createCell(2).setCellValue("Subcategory");
+ headerRow2.createCell(3).setCellValue("Value2");
+
+ // Create data rows
+ Row dataRow2 = sheet2.createRow(1);
+ dataRow2.createCell(0).setCellValue("Category B");
+ dataRow2.createCell(1).setCellValue(30);
+ dataRow2.createCell(2).setCellValue("Subcat B1");
+ dataRow2.createCell(3).setCellValue(40);
+
+ // Create third sheet with different headers
+ Sheet sheet3 = workbook.createSheet("Sheet 3");
+
+ // Create different header row (different column names)
+ Row headerRow3 = sheet3.createRow(0);
+ headerRow3.createCell(0).setCellValue("Group"); // Different name
+ headerRow3.createCell(1).setCellValue("Subcategory");
+ headerRow3.createCell(2).setCellValue("Score1"); // Different name
+ headerRow3.createCell(3).setCellValue("Score2"); // Different name
+
+ // Create data rows
+ Row dataRow3 = sheet3.createRow(1);
+ dataRow3.createCell(0).setCellValue("Category C");
+ dataRow3.createCell(1).setCellValue("Subcat C1");
+ dataRow3.createCell(2).setCellValue(50);
+ dataRow3.createCell(3).setCellValue(60);
+
+ // Write to file
+ try (FileOutputStream fileOut = new FileOutputStream(file)) {
+ workbook.write(fileOut);
+ }
+ workbook.close();
+ }
+
+ /**
+ * Creates a multi-sheet Excel file with mixed validity (some valid sheets, some invalid).
+ */
+ public static void createMixedValidityExcelFile(File file) throws IOException {
+ Workbook workbook = new XSSFWorkbook();
+
+ // Create first sheet (valid)
+ Sheet sheet1 = workbook.createSheet("Valid Sheet 1");
+
+ // Create header row
+ Row headerRow1 = sheet1.createRow(0);
+ headerRow1.createCell(0).setCellValue("Category");
+ headerRow1.createCell(1).setCellValue("Subcategory");
+ headerRow1.createCell(2).setCellValue("Value1");
+ headerRow1.createCell(3).setCellValue("Value2");
+
+ // Create data rows
+ Row dataRow1 = sheet1.createRow(1);
+ dataRow1.createCell(0).setCellValue("Category A");
+ dataRow1.createCell(1).setCellValue("Subcat A1");
+ dataRow1.createCell(2).setCellValue(10);
+ dataRow1.createCell(3).setCellValue(20);
+
+ // Create second sheet (empty - invalid)
+ workbook.createSheet("Empty Sheet");
+
+ // Create third sheet (valid - same header)
+ Sheet sheet3 = workbook.createSheet("Valid Sheet 2");
+
+ // Create header row (same as first sheet)
+ Row headerRow3 = sheet3.createRow(0);
+ headerRow3.createCell(0).setCellValue("Category");
+ headerRow3.createCell(1).setCellValue("Subcategory");
+ headerRow3.createCell(2).setCellValue("Value1");
+ headerRow3.createCell(3).setCellValue("Value2");
+
+ // Create data rows
+ Row dataRow3 = sheet3.createRow(1);
+ dataRow3.createCell(0).setCellValue("Category C");
+ dataRow3.createCell(1).setCellValue("Subcat C1");
+ dataRow3.createCell(2).setCellValue(50);
+ dataRow3.createCell(3).setCellValue(60);
+
+ // Create fourth sheet (invalid - fewer columns)
+ Sheet sheet4 = workbook.createSheet("Invalid Sheet - Fewer Columns");
+
+ // Create header row with fewer columns
+ Row headerRow4 = sheet4.createRow(0);
+ headerRow4.createCell(0).setCellValue("Category");
+ headerRow4.createCell(1).setCellValue("Value1");
+
+ // Create data row
+ Row dataRow4 = sheet4.createRow(1);
+ dataRow4.createCell(0).setCellValue("Category D");
+ dataRow4.createCell(1).setCellValue(70);
+
+ // Write to file
+ try (FileOutputStream fileOut = new FileOutputStream(file)) {
+ workbook.write(fileOut);
+ }
+ workbook.close();
+ }
+}
+
diff --git a/src/test/java/io/jenkins/plugins/reporter/provider/CreateExcelSample.java b/src/test/java/io/jenkins/plugins/reporter/provider/CreateExcelSample.java
new file mode 100644
index 00000000..255d8cac
--- /dev/null
+++ b/src/test/java/io/jenkins/plugins/reporter/provider/CreateExcelSample.java
@@ -0,0 +1,64 @@
+package io.jenkins.plugins.reporter.provider;
+
+import org.apache.poi.ss.usermodel.*;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+
+import java.io.FileOutputStream;
+import java.io.IOException;
+
+/**
+ * Utilitaire pour créer un fichier Excel de test dans le répertoire etc/
+ */
+public class CreateExcelSample {
+
+ public static void main(String[] args) {
+ String filePath = "/home/ubuntu/workspace/nested-data-reporting-plugin/etc/report.xlsx";
+
+ try {
+ // Créer un nouveau classeur Excel
+ Workbook workbook = new XSSFWorkbook();
+
+ // Créer une feuille
+ Sheet sheet = workbook.createSheet("Sample Data");
+
+ // Créer l'en-tête
+ Row headerRow = sheet.createRow(0);
+ headerRow.createCell(0).setCellValue("Category");
+ headerRow.createCell(1).setCellValue("Subcategory");
+ headerRow.createCell(2).setCellValue("Value1");
+ headerRow.createCell(3).setCellValue("Value2");
+
+ // Créer les données
+ Row row1 = sheet.createRow(1);
+ row1.createCell(0).setCellValue("Category A");
+ row1.createCell(1).setCellValue("");
+ row1.createCell(2).setCellValue(10);
+ row1.createCell(3).setCellValue(20);
+
+ Row row2 = sheet.createRow(2);
+ row2.createCell(0).setCellValue("Category B");
+ row2.createCell(1).setCellValue("");
+ row2.createCell(2).setCellValue(30);
+ row2.createCell(3).setCellValue(40);
+
+ Row row3 = sheet.createRow(3);
+ row3.createCell(0).setCellValue("Category C");
+ row3.createCell(1).setCellValue("");
+ row3.createCell(2).setCellValue(50);
+ row3.createCell(3).setCellValue(60);
+
+ // Écrire dans le fichier
+ try (FileOutputStream fileOut = new FileOutputStream(filePath)) {
+ workbook.write(fileOut);
+ }
+
+ // Fermer le classeur
+ workbook.close();
+
+ System.out.println("Fichier Excel créé avec succès à " + filePath);
+
+ } catch (IOException e) {
+ e.printStackTrace();
+ }
+ }
+}
diff --git a/src/test/java/io/jenkins/plugins/reporter/provider/ExcelMultiTest.java b/src/test/java/io/jenkins/plugins/reporter/provider/ExcelMultiTest.java
new file mode 100644
index 00000000..c406f578
--- /dev/null
+++ b/src/test/java/io/jenkins/plugins/reporter/provider/ExcelMultiTest.java
@@ -0,0 +1,358 @@
+package io.jenkins.plugins.reporter.provider;
+
+import io.jenkins.plugins.reporter.model.Item;
+import io.jenkins.plugins.reporter.model.ReportDto;
+import io.jenkins.plugins.reporter.model.ReportParser;
+import org.apache.poi.ss.usermodel.*;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.jvnet.hudson.test.JenkinsRule;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.List;
+import java.util.Optional;
+
+import static org.junit.Assert.*;
+
+/**
+ * Tests for the ExcelMulti provider class.
+ * This test suite validates the functionality of the ExcelMulti provider,
+ * which handles multi-sheet Excel files with header consistency enforcement.
+ */
+public class ExcelMultiTest {
+
+ @Rule
+ public JenkinsRule jenkinsRule = new JenkinsRule();
+
+ private File consistentHeadersFile;
+ private File inconsistentHeadersFile;
+ private File mixedValidityFile;
+ private ExcelMulti provider;
+
+ /**
+ * Set up the test files and provider before each test.
+ */
+ @Before
+ public void setUp() throws IOException {
+ // Generate the test files if they don't exist
+ String resourcesDir = "src/test/resources";
+ consistentHeadersFile = new File(resourcesDir, "test-excel-multi-consistent.xlsx");
+ inconsistentHeadersFile = new File(resourcesDir, "test-excel-multi-inconsistent.xlsx");
+ mixedValidityFile = new File(resourcesDir, "test-excel-multi-mixed.xlsx");
+
+ if (!consistentHeadersFile.exists()) {
+ CreateExcelMultiSample.createConsistentHeadersExcelFile(consistentHeadersFile);
+ }
+
+ if (!inconsistentHeadersFile.exists()) {
+ CreateExcelMultiSample.createInconsistentHeadersExcelFile(inconsistentHeadersFile);
+ }
+
+ if (!mixedValidityFile.exists()) {
+ CreateExcelMultiSample.createMixedValidityExcelFile(mixedValidityFile);
+ }
+
+ // Create and configure the provider
+ provider = new ExcelMulti();
+ provider.setId("test-excel-multi");
+
+ // Add debug logging in setUp
+ if (consistentHeadersFile.exists()) {
+ System.out.println("Test file exists: " + consistentHeadersFile.getAbsolutePath());
+ System.out.println("File size: " + consistentHeadersFile.length() + " bytes");
+ } else {
+ System.out.println("Test file does not exist: " + consistentHeadersFile.getAbsolutePath());
+ }
+
+ if (mixedValidityFile.exists()) {
+ System.out.println("Test file exists: " + mixedValidityFile.getAbsolutePath());
+ System.out.println("File size: " + mixedValidityFile.length() + " bytes");
+ } else {
+ System.out.println("Test file does not exist: " + mixedValidityFile.getAbsolutePath());
+ }
+
+ // Debug Excel structure
+ try (Workbook workbook = WorkbookFactory.create(consistentHeadersFile)) {
+ System.out.println("=== Excel Structure Debug (consistentHeadersFile) ===");
+ System.out.println("Number of sheets: " + workbook.getNumberOfSheets());
+ for (int i = 0; i < workbook.getNumberOfSheets(); i++) {
+ Sheet sheet = workbook.getSheetAt(i);
+ System.out.println("Sheet " + i + ": " + sheet.getSheetName());
+ Row headerRow = sheet.getRow(0);
+ if (headerRow != null) {
+ System.out.println(" Headers:");
+ for (Cell cell : headerRow) {
+ System.out.println(" - " + cell.getStringCellValue());
+ }
+ }
+ }
+ System.out.println("=========================");
+ } catch (Exception e) {
+ System.out.println("Error reading Excel structure: " + e.getMessage());
+ }
+
+ try (Workbook workbook = WorkbookFactory.create(mixedValidityFile)) {
+ System.out.println("=== Excel Structure Debug (mixedValidityFile) ===");
+ System.out.println("Number of sheets: " + workbook.getNumberOfSheets());
+ for (int i = 0; i < workbook.getNumberOfSheets(); i++) {
+ Sheet sheet = workbook.getSheetAt(i);
+ System.out.println("Sheet " + i + ": " + sheet.getSheetName());
+ Row headerRow = sheet.getRow(0);
+ if (headerRow != null) {
+ System.out.println(" Headers:");
+ for (Cell cell : headerRow) {
+ System.out.println(" - " + cell.getStringCellValue());
+ }
+ } else {
+ System.out.println(" No header row found");
+ }
+ }
+ System.out.println("=========================");
+ } catch (Exception e) {
+ System.out.println("Error reading Excel structure: " + e.getMessage());
+ }
+ }
+
+ /**
+ * Test the basic functionality of the ExcelMulti provider.
+ * This test verifies that the provider can parse a simple multi-sheet Excel file
+ * with consistent headers across all sheets.
+ */
+ @Test
+ public void testBasicFunctionality() throws IOException {
+ // Create the parser
+ ReportParser parser = provider.createParser();
+ assertNotNull("Parser should not be null", parser);
+
+ // Parse the file with consistent headers
+ ReportDto report = parser.parse(consistentHeadersFile);
+
+ // Verify the report is correctly parsed
+ assertNotNull("Report should not be null", report);
+ assertEquals("Report ID should match provider ID", "test-excel-multi", report.getId());
+
+ // Check that we have items
+ List
- items = report.getItems();
+ assertNotNull("Items list should not be null", items);
+ assertFalse("Items list should not be empty", items.isEmpty());
+ }
+
+ /**
+ * Test that data from all sheets with consistent headers is aggregated.
+ * This test verifies that the ExcelMulti provider correctly aggregates data
+ * from all sheets that have consistent headers.
+ */
+ @Test
+ public void testDataAggregation() throws IOException {
+ // Create the parser
+ ReportParser parser = provider.createParser();
+
+ // Parse the file with consistent headers
+ ReportDto report = parser.parse(consistentHeadersFile);
+
+ // Verify data from all sheets is included
+ List
- items = report.getItems();
+
+ // Debug logging
+ System.out.println("=== Data Aggregation Test Debug ===");
+ System.out.println("Total items found: " + items.size());
+ for (Item item : items) {
+ System.out.println("Found category: " + item.getName());
+ if (item.getItems() != null) {
+ for (Item subItem : item.getItems()) {
+ System.out.println(" - Subcategory: " + subItem.getName());
+ }
+ }
+ }
+ System.out.println("================================");
+
+ // Find items from different sheets
+ Optional
- categoryA = items.stream()
+ .filter(item -> item.getName().equals("Category A"))
+ .findFirst();
+
+ Optional
- categoryB = items.stream()
+ .filter(item -> item.getName().equals("Category B"))
+ .findFirst();
+
+ Optional
- categoryC = items.stream()
+ .filter(item -> item.getName().equals("Category C"))
+ .findFirst();
+
+ // Verify all categories are present (from different sheets)
+ assertTrue("Category A should be present", categoryA.isPresent());
+ assertTrue("Category B should be present", categoryB.isPresent());
+ assertTrue("Category C should be present", categoryC.isPresent());
+
+ // Verify subcategories for Category A
+ if (categoryA.isPresent()) {
+ List
- subcategoriesA = categoryA.get().getItems();
+ assertNotNull("Subcategories for Category A should not be null", subcategoriesA);
+ assertEquals("Category A should have 2 subcategories", 2, subcategoriesA.size());
+
+ // Check subcategory names
+ assertEquals("First subcategory of A should be Subcat A1",
+ "Subcat A1", subcategoriesA.get(0).getName());
+ assertEquals("Second subcategory of A should be Subcat A2",
+ "Subcat A2", subcategoriesA.get(1).getName());
+ }
+
+ // Verify subcategories for Category B
+ if (categoryB.isPresent()) {
+ List
- subcategoriesB = categoryB.get().getItems();
+ assertNotNull("Subcategories for Category B should not be null", subcategoriesB);
+ assertEquals("Category B should have 2 subcategories", 2, subcategoriesB.size());
+
+ // Check subcategory names
+ assertEquals("First subcategory of B should be Subcat B1",
+ "Subcat B1", subcategoriesB.get(0).getName());
+ assertEquals("Second subcategory of B should be Subcat B2",
+ "Subcat B2", subcategoriesB.get(1).getName());
+ }
+ }
+
+ /**
+ * Test that the ExcelMulti provider correctly enforces header consistency.
+ * This test verifies that only sheets with consistent headers are processed.
+ */
+ @Test
+ public void testHeaderConsistencyValidation() throws IOException {
+ // Create the parser and make it an instance of ExcelMultiParser to access parser messages
+ ExcelMulti.ExcelMultiParser parser = (ExcelMulti.ExcelMultiParser) provider.createParser();
+
+ // Parse the file with inconsistent headers
+ ReportDto report = parser.parse(inconsistentHeadersFile);
+
+ // Verify only data from the first sheet is included (as other sheets have inconsistent headers)
+ List
- items = report.getItems();
+
+ // There should only be Category A (from first sheet)
+ Optional
- categoryA = items.stream()
+ .filter(item -> item.getName().equals("Category A"))
+ .findFirst();
+
+ Optional
- categoryB = items.stream()
+ .filter(item -> item.getName().equals("Category B"))
+ .findFirst();
+
+ Optional
- categoryC = items.stream()
+ .filter(item -> item.getName().equals("Category C"))
+ .findFirst();
+
+ // Verify only Category A is present (as other sheets have inconsistent headers)
+ assertTrue("Category A should be present", categoryA.isPresent());
+ assertFalse("Category B should not be present", categoryB.isPresent());
+ assertFalse("Category C should not be present", categoryC.isPresent());
+ }
+
+ /**
+ * Test that the ExcelMulti provider correctly handles invalid sheets.
+ * This test verifies that invalid sheets are skipped and valid sheets are processed.
+ */
+ @Test
+ public void testHandlingInvalidSheets() throws IOException {
+ // Create the parser
+ ReportParser parser = provider.createParser();
+
+ // Parse the file with mixed validity
+ ReportDto report = parser.parse(mixedValidityFile);
+
+ // Verify only data from valid sheets is included
+ List
- items = report.getItems();
+
+ // Debug logging
+ System.out.println("=== Invalid Sheets Test Debug ===");
+ System.out.println("Total items found: " + items.size());
+ for (Item item : items) {
+ System.out.println("Found category: " + item.getName());
+ if (item.getItems() != null) {
+ for (Item subItem : item.getItems()) {
+ System.out.println(" - Subcategory: " + subItem.getName());
+ }
+ }
+ }
+ System.out.println("================================");
+
+ // There should be Categories A and C (from valid sheets)
+ Optional
- categoryA = items.stream()
+ .filter(item -> item.getName().equals("Category A"))
+ .findFirst();
+
+ Optional
- categoryC = items.stream()
+ .filter(item -> item.getName().equals("Category C"))
+ .findFirst();
+
+ Optional
- categoryD = items.stream()
+ .filter(item -> item.getName().equals("Category D"))
+ .findFirst();
+
+ // Verify only Categories A and C are present (from valid sheets)
+ assertTrue("Category A should be present", categoryA.isPresent());
+ assertTrue("Category C should be present", categoryC.isPresent());
+ assertFalse("Category D should not be present", categoryD.isPresent());
+ }
+
+ /**
+ * Test that the ExcelMulti provider generates appropriate parser messages.
+ * This test verifies that detailed messages are generated for skipped sheets.
+ */
+ @Test
+ public void testParserMessages() throws IOException {
+ // Create the parser and make it an instance of ExcelMultiParser to access parser messages
+ ExcelMulti.ExcelMultiParser parser = (ExcelMulti.ExcelMultiParser) provider.createParser();
+
+ // Parse the file with mixed validity
+ parser.parse(mixedValidityFile);
+
+ // Get parser messages
+ List messages = parser.parserMessages;
+
+ // Verify that parser messages were generated
+ assertFalse("Parser messages should not be empty", messages.isEmpty());
+
+ // Check for expected messages
+ boolean foundInitialMessage = false;
+ boolean foundSkippedEmptySheet = false;
+ boolean foundSkippedFewerColumnsSheet = false;
+ boolean foundSummaryMessage = false;
+
+ for (String message : messages) {
+ if (message.contains("Excel file contains")) {
+ foundInitialMessage = true;
+ }
+ if (message.contains("Empty Sheet")) {
+ foundSkippedEmptySheet = true;
+ }
+ if (message.contains("Invalid Sheet - Fewer Columns")) {
+ foundSkippedFewerColumnsSheet = true;
+ }
+ if (message.contains("Skipped") && message.contains("sheets:")) {
+ foundSummaryMessage = true;
+ }
+ }
+
+ assertTrue("Should have initial message about sheet count", foundInitialMessage);
+ assertTrue("Should have message about skipped empty sheet", foundSkippedEmptySheet);
+ assertTrue("Should have message about skipped sheet with fewer columns", foundSkippedFewerColumnsSheet);
+ assertTrue("Should have summary message about skipped sheets", foundSummaryMessage);
+ }
+
+ /**
+ * Test that the ExcelMulti provider correctly handles error cases.
+ * This test verifies that appropriate exceptions are thrown for invalid files.
+ */
+ @Test(expected = IOException.class)
+ public void testErrorHandling() throws IOException {
+ // Create the parser
+ ReportParser parser = provider.createParser();
+
+ // Try to parse a non-existent file
+ File nonExistentFile = new File("non-existent-file.xlsx");
+ parser.parse(nonExistentFile);
+
+ // Should throw IOException
+ }
+}
+
diff --git a/src/test/java/io/jenkins/plugins/reporter/provider/ExcelTestFileGenerator.java b/src/test/java/io/jenkins/plugins/reporter/provider/ExcelTestFileGenerator.java
new file mode 100644
index 00000000..a9e12634
--- /dev/null
+++ b/src/test/java/io/jenkins/plugins/reporter/provider/ExcelTestFileGenerator.java
@@ -0,0 +1,130 @@
+package io.jenkins.plugins.reporter.provider;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import org.apache.poi.ss.usermodel.*;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
+
+/**
+ * Utility class to generate test Excel files for testing the Excel parser functionality.
+ */
+public class ExcelTestFileGenerator {
+
+ public static void main(String[] args) throws IOException {
+ // Create test files in the resources directory
+ String resourcesDir = "src/test/resources";
+
+ // Create normal Excel file
+ createNormalExcelFile(new File(resourcesDir, "test-excel-normal.xlsx"));
+
+ // Create Excel file with offset header
+ createOffsetHeaderExcelFile(new File(resourcesDir, "test-excel-offset.xlsx"));
+
+ // Create Excel file with mixed data
+ createMixedDataExcelFile(new File(resourcesDir, "test-excel-mixed.xlsx"));
+
+ System.out.println("Test Excel files created successfully in " + resourcesDir);
+ }
+
+ /**
+ * Creates a normal Excel file with header in the first row and data below.
+ */
+ public static void createNormalExcelFile(File file) throws IOException {
+ Workbook workbook = new XSSFWorkbook();
+ Sheet sheet = workbook.createSheet("Test Sheet");
+
+ // Create header row
+ Row headerRow = sheet.createRow(0);
+ headerRow.createCell(0).setCellValue("Category");
+ headerRow.createCell(1).setCellValue("Subcategory");
+ headerRow.createCell(2).setCellValue("Value1");
+ headerRow.createCell(3).setCellValue("Value2");
+
+ // Create data rows
+ Row dataRow1 = sheet.createRow(1);
+ dataRow1.createCell(0).setCellValue("Category A");
+ dataRow1.createCell(1).setCellValue("");
+ dataRow1.createCell(2).setCellValue(10);
+ dataRow1.createCell(3).setCellValue(20);
+
+ Row dataRow2 = sheet.createRow(2);
+ dataRow2.createCell(0).setCellValue("Category B");
+ dataRow2.createCell(1).setCellValue("");
+ dataRow2.createCell(2).setCellValue(30);
+ dataRow2.createCell(3).setCellValue(40);
+
+ // Write to file
+ try (FileOutputStream fileOut = new FileOutputStream(file)) {
+ workbook.write(fileOut);
+ }
+ workbook.close();
+ }
+
+ /**
+ * Creates an Excel file with header not in the first row.
+ */
+ public static void createOffsetHeaderExcelFile(File file) throws IOException {
+ Workbook workbook = new XSSFWorkbook();
+ Sheet sheet = workbook.createSheet("Test Sheet");
+
+ // Add some empty rows and cells
+ sheet.createRow(0).createCell(0).setCellValue("This is not the header");
+ sheet.createRow(1); // Empty row
+
+ // Create header row at position 3
+ Row headerRow = sheet.createRow(3);
+ headerRow.createCell(0).setCellValue("Category");
+ headerRow.createCell(1).setCellValue("Subcategory");
+ headerRow.createCell(2).setCellValue("Value1");
+ headerRow.createCell(3).setCellValue("Value2");
+
+ // Create data rows
+ Row dataRow1 = sheet.createRow(4);
+ dataRow1.createCell(0).setCellValue("Category A");
+ dataRow1.createCell(1).setCellValue("");
+ dataRow1.createCell(2).setCellValue(10);
+ dataRow1.createCell(3).setCellValue(20);
+
+ // Write to file
+ try (FileOutputStream fileOut = new FileOutputStream(file)) {
+ workbook.write(fileOut);
+ }
+ workbook.close();
+ }
+
+ /**
+ * Creates an Excel file with mixed data types and nested structure.
+ */
+ public static void createMixedDataExcelFile(File file) throws IOException {
+ Workbook workbook = new XSSFWorkbook();
+ Sheet sheet = workbook.createSheet("Test Sheet");
+
+ // Create header row
+ Row headerRow = sheet.createRow(0);
+ headerRow.createCell(0).setCellValue("Parent");
+ headerRow.createCell(1).setCellValue("Child");
+ headerRow.createCell(2).setCellValue("Value1");
+ headerRow.createCell(3).setCellValue("Value2");
+
+ // Create data rows with nested structure
+ Row dataRow1 = sheet.createRow(1);
+ dataRow1.createCell(0).setCellValue("Parent");
+ dataRow1.createCell(1).setCellValue("Child");
+ dataRow1.createCell(2).setCellValue(30);
+ dataRow1.createCell(3).setCellValue(40);
+
+ // Add some rows with different data types
+ Row dataRow2 = sheet.createRow(2);
+ dataRow2.createCell(0).setCellValue("Parent");
+ dataRow2.createCell(1).setCellValue("Child2");
+ dataRow2.createCell(2).setCellValue("Not a number"); // String instead of number
+ dataRow2.createCell(3).setCellValue(50);
+
+ // Write to file
+ try (FileOutputStream fileOut = new FileOutputStream(file)) {
+ workbook.write(fileOut);
+ }
+ workbook.close();
+ }
+}
diff --git a/src/test/java/io/jenkins/plugins/reporter/provider/RegenerateTestFiles.java b/src/test/java/io/jenkins/plugins/reporter/provider/RegenerateTestFiles.java
new file mode 100644
index 00000000..81bb00f1
--- /dev/null
+++ b/src/test/java/io/jenkins/plugins/reporter/provider/RegenerateTestFiles.java
@@ -0,0 +1,31 @@
+package io.jenkins.plugins.reporter.provider;
+
+import java.io.File;
+import java.io.IOException;
+
+/**
+ * Temporary utility class to regenerate test Excel files.
+ * Run this class to update test files before running tests.
+ */
+public class RegenerateTestFiles {
+
+ public static void main(String[] args) throws IOException {
+ // Temporary file to regenerate test data
+ File resourcesDir = new File("src/test/resources");
+ if (!resourcesDir.exists()) {
+ resourcesDir.mkdirs();
+ }
+
+ System.out.println("Regenerating test Excel files...");
+
+ CreateExcelMultiSample.createConsistentHeadersExcelFile(
+ new File(resourcesDir, "test-excel-multi-consistent.xlsx"));
+ CreateExcelMultiSample.createInconsistentHeadersExcelFile(
+ new File(resourcesDir, "test-excel-multi-inconsistent.xlsx"));
+ CreateExcelMultiSample.createMixedValidityExcelFile(
+ new File(resourcesDir, "test-excel-multi-mixed.xlsx"));
+
+ System.out.println("Test Excel files regenerated successfully!");
+ }
+}
+
diff --git a/src/test/resources/test-excel-mixed.xlsx b/src/test/resources/test-excel-mixed.xlsx
new file mode 100644
index 00000000..8a522ac1
Binary files /dev/null and b/src/test/resources/test-excel-mixed.xlsx differ
diff --git a/src/test/resources/test-excel-multi-consistent.xlsx b/src/test/resources/test-excel-multi-consistent.xlsx
new file mode 100644
index 00000000..8ca5d849
Binary files /dev/null and b/src/test/resources/test-excel-multi-consistent.xlsx differ
diff --git a/src/test/resources/test-excel-multi-inconsistent.xlsx b/src/test/resources/test-excel-multi-inconsistent.xlsx
new file mode 100644
index 00000000..e4113032
Binary files /dev/null and b/src/test/resources/test-excel-multi-inconsistent.xlsx differ
diff --git a/src/test/resources/test-excel-multi-mixed.xlsx b/src/test/resources/test-excel-multi-mixed.xlsx
new file mode 100644
index 00000000..01d877b3
Binary files /dev/null and b/src/test/resources/test-excel-multi-mixed.xlsx differ
diff --git a/src/test/resources/test-excel-normal.xlsx b/src/test/resources/test-excel-normal.xlsx
new file mode 100644
index 00000000..0f3b6a59
Binary files /dev/null and b/src/test/resources/test-excel-normal.xlsx differ
diff --git a/src/test/resources/test-excel-offset.xlsx b/src/test/resources/test-excel-offset.xlsx
new file mode 100644
index 00000000..635ac393
Binary files /dev/null and b/src/test/resources/test-excel-offset.xlsx differ
diff --git a/src/test/resources/test-excel.xlsx b/src/test/resources/test-excel.xlsx
new file mode 100644
index 00000000..76873415
--- /dev/null
+++ b/src/test/resources/test-excel.xlsx
@@ -0,0 +1 @@
+PK