diff --git a/README.md b/README.md index e2b98a0..353b550 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Whether you're tracking metrics, analyzing trends, or monitoring performance, th ## Key Features - **Visualize Nested Data**: Display hierarchical data structures in pie charts, trend charts, and tables. -- **Multiple File Formats**: Supports JSON, YAML, XML, and CSV files. +- **Multiple File Formats**: Supports JSON, YAML, XML, CSV, and Excel (.xls, .xlsx) files. - **Dynamic UI**: Interactive charts and tables that update based on your data. - **Customizable Colors**: Define custom colors for your data points or use predefined color schemes. - **Trend Analysis**: Track data trends over multiple builds with history charts. @@ -85,6 +85,34 @@ The plugin supports the following file formats for data input: #### YAML and XML - Similar hierarchical structures as JSON are supported. +#### Excel (`excel` provider) +- This provider parses a single Excel sheet from an `.xls` or `.xlsx` file. By default, it processes the **first sheet** in the workbook. +- **Structure Expectation:** + - The parser automatically detects the header row (the first non-empty row). + - Columns *before* the first column containing predominantly numeric data are treated as hierarchy levels. + - Columns *from* the first numeric-looking column onwards are treated as data values, with their respective header names as keys for the results. +- **Example Data (conceptual view of a sheet):** + ``` + (Sheet1 in an .xlsx or .xls file) + Category, SubCategory, Metric1, Value2 + Alpha, X, 10, 100 + Alpha, Y, 15, 150 + Beta, Z, 20, 200 + ``` + In this example: + - "Category" and "SubCategory" would form the hierarchy (e.g., Alpha -> X). + - "Metric1" and "Value2" would be the data keys with their corresponding numeric values. +- Empty rows before the header or between data rows are typically ignored. + +#### Multi-Sheet Excel (`excelmulti` provider) +- This provider parses **all sheets** in an Excel workbook (.xls or .xlsx). +- **Header Consistency Requirement:** + - The header from the *first successfully parsed sheet* (first non-empty sheet with a valid header) is used as a reference. + - Subsequent sheets **must have an identical header** (same column names in the same order) to be included in the report. + - Sheets with headers that do not match the reference header will be skipped, and a warning will be logged. +- **Data Structure per Sheet:** Within each sheet, the data structure expectation is the same as for the `excel` provider (auto-detected header, hierarchy based on pre-numeric columns, values from numeric columns onwards). +- Item IDs are generated to be unique across sheets, typically by internally prefixing them with sheet-specific information. + --- ## Color Management @@ -95,7 +123,7 @@ The plugin allows you to customize the colors used in the visualizations. You ca To customize colors, add a `colors` object to your JSON, YAML, or XML file. The `colors` object should map metric keys or category names to specific colors. Colors can be defined using **HEX values** or **predefined color names**. -> **Note**: Color customization is **not supported for CSV files** due to the format does not allow color attribute definition. For now, colors are attributed aleatory. +> **Note**: Color customization is **not supported for CSV or Excel files** as these formats do not have a standard way to define color attributes within the data file itself for this plugin's use. For CSV and Excel, colors are attributed automatically by the charting libraries. #### Example in JSON: ```json @@ -146,9 +174,17 @@ You can interact with the charts and tables to drill down into specific data poi - `relative`: Show percentage values. - `dual`: Show both absolute and relative values. - **`provider`**: Specify the file format and pattern for the data files. - - **`id`**: (Required for CSV) A unique identifier for the report. + - **`id`**: (Optional, but recommended for CSV, Excel, and ExcelMulti if multiple reports of the same type are used) A unique identifier for the report instance. This helps in creating distinct report URLs and managing history, especially if you have multiple CSV or Excel reports in the same job. - **`pattern`**: An Ant-style pattern to locate the data files. + **Examples for `provider`:** + - JSON: `provider: json(pattern: 'reports/**/*.json')` + - CSV: `provider: csv(id: 'my-csv-report', pattern: 'reports/data.csv')` + - Excel (single sheet): `provider: excel(pattern: 'reports/data.xlsx')` + - Excel (multi-sheet): `provider: excelmulti(pattern: 'reports/multi_sheet_data.xlsx')` + - You can also add an `id` to `excel` and `excelmulti` if needed: + `provider: excel(id: 'my-excel-report', pattern: 'reports/data.xlsx')` + ## Examples diff --git a/pom.xml b/pom.xml index aba68c3..340a0f0 100644 --- a/pom.xml +++ b/pom.xml @@ -117,6 +117,25 @@ jackson2-api + + + org.apache.poi + poi + 5.4.1 + + + org.apache.poi + poi-ooxml + 5.4.1 + + + + + com.google.code.findbugs + jsr305 + 3.0.2 + + org.jenkins-ci.plugins.workflow diff --git a/src/main/java/io/jenkins/plugins/reporter/model/ExcelParserConfig.java b/src/main/java/io/jenkins/plugins/reporter/model/ExcelParserConfig.java new file mode 100644 index 0000000..6e19bb5 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/reporter/model/ExcelParserConfig.java @@ -0,0 +1,20 @@ +package io.jenkins.plugins.reporter.model; + +import java.io.Serializable; + +public class ExcelParserConfig implements Serializable { + + private static final long serialVersionUID = 1L; + + // Future configuration options can be added here, for example: + // private int headerRowIndex = 0; // Default to the first row + // private int dataStartRowIndex = 1; // Default to the second row + // private String sheetName; // For single sheet parsing, if specified + // private boolean detectHeadersAutomatically = true; + + public ExcelParserConfig() { + // Default constructor + } + + // Add getters and setters here if fields are added in the future. +} 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 d5ebe13..811d031 100644 --- a/src/main/java/io/jenkins/plugins/reporter/model/Item.java +++ b/src/main/java/io/jenkins/plugins/reporter/model/Item.java @@ -72,9 +72,14 @@ public LinkedHashMap getResult() { return result; } - return getItems() + // NPE fix: check if items list is null or empty before streaming + if (items == null || items.isEmpty()) { // items is the List field + return new LinkedHashMap<>(); // Return empty map if no sub-items to aggregate from + } + + return items // Now items is guaranteed not to be null and not empty .stream() - .map(Item::getResult) + .map(Item::getResult) // Recursive call .flatMap(map -> map.entrySet().stream()) .collect(Collectors.groupingBy(Map.Entry::getKey, LinkedHashMap::new, Collectors.summingInt(Map.Entry::getValue))); } @@ -114,6 +119,9 @@ public void setItems(List items) { } public void addItem(Item item) { + if (this.items == null) { + this.items = new ArrayList<>(); + } this.items.add(item); } } \ No newline at end of file diff --git a/src/main/java/io/jenkins/plugins/reporter/model/ReportDto.java b/src/main/java/io/jenkins/plugins/reporter/model/ReportDto.java index 08d020b..5d65a6e 100644 --- a/src/main/java/io/jenkins/plugins/reporter/model/ReportDto.java +++ b/src/main/java/io/jenkins/plugins/reporter/model/ReportDto.java @@ -19,6 +19,10 @@ public class ReportDto extends ReportBase { @JsonInclude(JsonInclude.Include.NON_NULL) private Map colors; + @JsonProperty(value = "parserLogMessages") + @JsonInclude(JsonInclude.Include.NON_EMPTY) // Only include in JSON if not empty + private List parserLogMessages; + public String getId() { return id; } @@ -42,6 +46,14 @@ public Map getColors() { public void setColors(Map colors) { this.colors = colors; } + + public List getParserLogMessages() { + return parserLogMessages; + } + + public void setParserLogMessages(List parserLogMessages) { + this.parserLogMessages = parserLogMessages; + } @JsonIgnore public Report toReport() { diff --git a/src/main/java/io/jenkins/plugins/reporter/parser/AbstractReportParserBase.java b/src/main/java/io/jenkins/plugins/reporter/parser/AbstractReportParserBase.java new file mode 100644 index 0000000..b89f824 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/reporter/parser/AbstractReportParserBase.java @@ -0,0 +1,210 @@ +package io.jenkins.plugins.reporter.parser; + +import io.jenkins.plugins.reporter.model.Item; +import io.jenkins.plugins.reporter.model.ReportDto; +import io.jenkins.plugins.reporter.model.ReportParser; // Extends this +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.math.NumberUtils; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Optional; +import java.util.logging.Logger; + + +public abstract class AbstractReportParserBase extends ReportParser { + + private static final long serialVersionUID = 5738290018231028471L; // New UID + protected static final Logger PARSER_LOGGER = Logger.getLogger(AbstractReportParserBase.class.getName()); + + /** + * Detects the column structure (hierarchy vs. value columns) of a report. + * + * @param header The list of header strings. + * @param firstDataRow A list of string values from the first representative data row. + * @param messagesCollector A list to collect informational/warning messages. + * @param parserName A short name of the parser type (e.g., "CSV", "Excel") for message logging. + * @return The starting column index for value/numeric data. Returns -1 if structure cannot be determined or is invalid. + */ + protected int detectColumnStructure(List header, List firstDataRow, List messagesCollector, String parserName) { + if (header == null || header.isEmpty()) { + messagesCollector.add(String.format("Warning [%s]: Header is empty, cannot detect column structure.", parserName)); + return -1; + } + if (firstDataRow == null || firstDataRow.isEmpty()) { + messagesCollector.add(String.format("Warning [%s]: First data row is empty, cannot reliably detect column structure.", parserName)); + // Proceed assuming last column is value if header has multiple columns, else ambiguous. + if (header.size() > 1) { + messagesCollector.add(String.format("Info [%s]: Defaulting structure: Assuming last column ('%s') for values due to empty first data row.", parserName, header.get(header.size() -1))); + return header.size() - 1; + } else if (header.size() == 1) { + messagesCollector.add(String.format("Info [%s]: Single column header ('%s') and empty first data row. Structure ambiguous.", parserName, header.get(0))); + return 0; // Treat as value column by default + } + return -1; + } + + int determinedColIdxValueStart = 0; + for (int cIdx = header.size() - 1; cIdx >= 0; cIdx--) { + String cellVal = (cIdx < firstDataRow.size()) ? firstDataRow.get(cIdx) : ""; + if (NumberUtils.isCreatable(cellVal)) { + determinedColIdxValueStart = cIdx; + } else { + if (determinedColIdxValueStart > cIdx && determinedColIdxValueStart != 0) { + break; + } + } + } + + if (determinedColIdxValueStart == 0 && !NumberUtils.isCreatable(firstDataRow.get(0))) { + if (header.size() > 1) { + determinedColIdxValueStart = header.size() - 1; + messagesCollector.add(String.format("Warning [%s]: No numeric columns auto-detected. Assuming last column ('%s') for values.", parserName, header.get(determinedColIdxValueStart))); + } else { + messagesCollector.add(String.format("Info [%s]: Single text column ('%s'). No numeric data values expected.", parserName, header.get(0))); + } + } else if (determinedColIdxValueStart == 0 && NumberUtils.isCreatable(firstDataRow.get(0))) { + messagesCollector.add(String.format("Info [%s]: First column ('%s') is numeric. Treating it as the first value column.", parserName, header.get(0))); + } + + messagesCollector.add(String.format("Info [%s]: Detected data structure: Hierarchy/Text columns: 0 to %d, Value/Numeric columns: %d to %d.", + parserName, Math.max(0, determinedColIdxValueStart - 1), determinedColIdxValueStart, header.size() - 1)); + + if (determinedColIdxValueStart >= header.size() || determinedColIdxValueStart < 0) { + messagesCollector.add(String.format("Error [%s]: Invalid structure detected (value_start_index %d out of bounds for header size %d).", + parserName, determinedColIdxValueStart, header.size())); + return -1; // Invalid structure + } + return determinedColIdxValueStart; + } + + protected void parseRowToItems(ReportDto reportDto, List rowValues, List header, + int colIdxValueStart, String baseItemIdPrefix, + List messagesCollector, String parserName, int rowIndexForLog) { + + if (rowValues == null || rowValues.isEmpty()) { + messagesCollector.add(String.format("Info [%s]: Skipped empty row at data index %d.", parserName, rowIndexForLog)); + return; + } + + if (rowValues.stream().allMatch(StringUtils::isBlank)) { + messagesCollector.add(String.format("Info [%s]: Skipped row with all blank cells at data index %d.", parserName, rowIndexForLog)); + return; + } + + if (rowValues.size() < colIdxValueStart && colIdxValueStart > 0) { + messagesCollector.add(String.format("Warning [%s]: Skipped data row at index %d: Row has %d cells, but hierarchy part expects at least %d.", + parserName, rowIndexForLog, rowValues.size(), colIdxValueStart)); + return; + } + + String parentId = "report"; + Item lastItem = null; + boolean lastItemWasNewlyCreated = false; + LinkedHashMap resultValuesMap = new LinkedHashMap<>(); + boolean issueInHierarchy = false; + String currentItemPathId = StringUtils.isNotBlank(baseItemIdPrefix) ? baseItemIdPrefix + "::" : ""; + + for (int colIdx = 0; colIdx < header.size(); colIdx++) { + String headerName = header.get(colIdx); + String rawCellValue = (colIdx < rowValues.size() && rowValues.get(colIdx) != null) ? rowValues.get(colIdx).trim() : ""; + + if (colIdx < colIdxValueStart) { + String hierarchyCellValue = rawCellValue; + String originalCellValueForName = rawCellValue; + + if (StringUtils.isBlank(hierarchyCellValue)) { + if (colIdx == 0) { + messagesCollector.add(String.format("Warning [%s]: Skipped data row at index %d: First hierarchy column ('%s') is empty.", + parserName, rowIndexForLog, headerName)); + issueInHierarchy = true; + break; + } + messagesCollector.add(String.format("Info [%s]: Data row index %d, Col %d (Header '%s') is part of hierarchy and is blank. Using placeholder ID part.", + parserName, rowIndexForLog, colIdx + 1, headerName)); + hierarchyCellValue = "blank_hier_" + colIdx; + } else if (NumberUtils.isCreatable(hierarchyCellValue)) { + messagesCollector.add(String.format("Info [%s]: Data row index %d, Col %d (Header '%s') is part of hierarchy but is numeric-like ('%s'). Using as string for ID/Name.", + parserName, rowIndexForLog, colIdx + 1, headerName, hierarchyCellValue)); + } + + currentItemPathId += hierarchyCellValue.replaceAll("[^a-zA-Z0-9_-]", "_") + "_"; + String itemId = StringUtils.removeEnd(currentItemPathId, "_"); + if (StringUtils.isBlank(itemId)) { + itemId = baseItemIdPrefix + "::unnamed_item_r" + rowIndexForLog + "_c" + colIdx; + } + + Optional parentOpt = reportDto.findItem(parentId, reportDto.getItems()); + Item currentItem = new Item(); + currentItem.setId(StringUtils.abbreviate(itemId, 250)); + currentItem.setName(StringUtils.isBlank(originalCellValueForName) ? "(blank)" : originalCellValueForName); + lastItemWasNewlyCreated = false; + + if (parentOpt.isPresent()) { + Item p = parentOpt.get(); + if (p.getItems() == null) p.setItems(new ArrayList<>()); + + Optional existingItem = p.getItems().stream().filter(it -> it.getId().equals(currentItem.getId())).findFirst(); + if (!existingItem.isPresent()) { + p.addItem(currentItem); + lastItemWasNewlyCreated = true; + lastItem = currentItem; + } else { + lastItem = existingItem.get(); + } + } else { + Optional existingRootItem = reportDto.getItems().stream().filter(it -> it.getId().equals(currentItem.getId())).findFirst(); + if (!existingRootItem.isPresent()) { + if (reportDto.getItems() == null) reportDto.setItems(new ArrayList<>()); + reportDto.getItems().add(currentItem); + lastItemWasNewlyCreated = true; + lastItem = currentItem; + } else { + lastItem = existingRootItem.get(); + } + } + parentId = currentItem.getId(); + } else { + Number numValue = 0; + if (NumberUtils.isCreatable(rawCellValue)) { + numValue = NumberUtils.createNumber(rawCellValue); + } else if (StringUtils.isNotBlank(rawCellValue)) { + messagesCollector.add(String.format("Warning [%s]: Non-numeric value '%s' in data column '%s' at data row index %d, col %d. Using 0.", + parserName, rawCellValue, headerName, rowIndexForLog, colIdx + 1)); + } + resultValuesMap.put(headerName, numValue.intValue()); + } + } + + if (issueInHierarchy) { + return; + } + + if (lastItem != null) { + if (lastItem.getResult() == null || lastItemWasNewlyCreated) { + lastItem.setResult(resultValuesMap); + } else { + messagesCollector.add(String.format("Info [%s]: Item '%s' (data row index %d) already had results. New values for this row were: %s. Not overwriting existing results.", + parserName, lastItem.getId(), rowIndexForLog, resultValuesMap.toString())); + } + } else if (!resultValuesMap.isEmpty()) { + messagesCollector.add(String.format("Debug [%s]: In parseRowToItems - creating direct data item. Row: %d, BaseID: %s, ColIdxValueStart: %d, Results: %s", + parserName, rowIndexForLog, baseItemIdPrefix, colIdxValueStart, resultValuesMap.toString())); + Item valueItem = new Item(); + String generatedId = (StringUtils.isNotBlank(baseItemIdPrefix) ? baseItemIdPrefix + "::" : "") + "DataRow_" + rowIndexForLog; + valueItem.setId(StringUtils.abbreviate(generatedId.replaceAll("[^a-zA-Z0-9_.-]", "_"), 100)); + valueItem.setName("Data Row " + (rowIndexForLog + 1)); + valueItem.setResult(resultValuesMap); + if (reportDto.getItems() == null) reportDto.setItems(new ArrayList<>()); + reportDto.getItems().add(valueItem); + messagesCollector.add(String.format("Info [%s]: Data row index %d created as a direct data item '%s' as no distinct hierarchy path was formed (or colIdxValueStart was 0).", + parserName, rowIndexForLog, valueItem.getName())); + } else if (lastItem == null && resultValuesMap.isEmpty() && header.size() > 0) { + messagesCollector.add(String.format("Debug [%s]: In parseRowToItems - row yielded no hierarchy item and no results. Row: %d, BaseID: %s, ColIdxValueStart: %d", + parserName, rowIndexForLog, baseItemIdPrefix, colIdxValueStart)); + messagesCollector.add(String.format("Warning [%s]: Data row index %d did not yield any identifiable hierarchy item or data values. It might be effectively empty or malformed relative to header.", + parserName, rowIndexForLog)); + } + } +} diff --git a/src/main/java/io/jenkins/plugins/reporter/parser/BaseExcelParser.java b/src/main/java/io/jenkins/plugins/reporter/parser/BaseExcelParser.java new file mode 100644 index 0000000..085271f --- /dev/null +++ b/src/main/java/io/jenkins/plugins/reporter/parser/BaseExcelParser.java @@ -0,0 +1,194 @@ +package io.jenkins.plugins.reporter.parser; + +import io.jenkins.plugins.reporter.model.ExcelParserConfig; +import io.jenkins.plugins.reporter.model.ReportDto; +// import io.jenkins.plugins.reporter.model.ReportParser; // No longer directly needed, comes from AbstractReportParserBase +import io.jenkins.plugins.reporter.parser.AbstractReportParserBase; // Added +import org.apache.commons.lang3.StringUtils; +import org.apache.poi.ss.usermodel.*; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; +import org.apache.poi.hssf.usermodel.HSSFWorkbook; + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.logging.Logger; +import java.util.stream.Collectors; + + +public abstract class BaseExcelParser extends AbstractReportParserBase { // Changed superclass + + private static final long serialVersionUID = 1L; // Keep existing or update if major structural change + // protected static final Logger LOGGER = Logger.getLogger(BaseExcelParser.class.getName()); // Use PARSER_LOGGER from base class + // No, PARSER_LOGGER in AbstractReportParserBase is for that class. Keep this one for BaseExcelParser specific logs. + protected static final Logger LOGGER = Logger.getLogger(BaseExcelParser.class.getName()); + + + protected final ExcelParserConfig config; + + protected BaseExcelParser(ExcelParserConfig config) { + this.config = config; + } + + @Override + public ReportDto parse(File file) throws IOException { + ReportDto aggregatedReport = new ReportDto(); + aggregatedReport.setItems(new ArrayList<>()); + // aggregatedReport.setParserLog(new ArrayList<>()); // If you add logging messages + + try (InputStream is = new FileInputStream(file)) { + Workbook workbook; + String fileName = file.getName().toLowerCase(); + if (fileName.endsWith(".xlsx")) { + workbook = new XSSFWorkbook(is); + } else if (fileName.endsWith(".xls")) { + workbook = new HSSFWorkbook(is); + } else { + throw new IllegalArgumentException("File format not supported. Please use .xls or .xlsx: " + file.getName()); + } + + // Logic for iterating sheets will be determined by subclasses. + // For now, this base `parse` method might be too generic if subclasses + // have very different sheet iteration strategies (e.g., first vs. all). + // Consider making this method abstract or providing a hook for sheet selection. + // For this iteration, let's assume the subclass will guide sheet processing. + // This method will primarily ensure the workbook is opened and closed correctly. + + // This part needs to be implemented by subclasses by calling parseSheet + // For example, a subclass might iterate through all sheets: + // for (int i = 0; i < workbook.getNumberOfSheets(); i++) { + // Sheet sheet = workbook.getSheetAt(i); + // ReportDto sheetReport = parseSheet(sheet, sheet.getSheetName(), this.config, createReportId(file.getName(), sheet.getSheetName())); + // // Aggregate sheetReport into aggregatedReport + // } + // Or a subclass might parse only the first sheet: + // if (workbook.getNumberOfSheets() > 0) { + // Sheet firstSheet = workbook.getSheetAt(0); + // aggregatedReport = parseSheet(firstSheet, firstSheet.getSheetName(), this.config, createReportId(file.getName())); + // } + + + } catch (Exception e) { + LOGGER.severe("Error parsing Excel file " + file.getName() + ": " + e.getMessage()); + // aggregatedReport.addParserMessage("Error parsing file: " + e.getMessage()); + throw new IOException("Error parsing Excel file: " + file.getName(), e); + } + + return aggregatedReport; // This will be populated by subclass logic calling parseSheet + } + + protected abstract ReportDto parseSheet(Sheet sheet, String sheetName, ExcelParserConfig config, String reportId); + + protected String getCellValueAsString(Cell cell) { + if (cell == null) { + return ""; + } + switch (cell.getCellType()) { + case STRING: + return cell.getStringCellValue().trim(); + case NUMERIC: + if (DateUtil.isCellDateFormatted(cell)) { + return cell.getDateCellValue().toString(); // Or format as needed + } else { + // Format as string, avoiding ".0" for integers + double numericValue = cell.getNumericCellValue(); + if (numericValue == (long) numericValue) { + return String.format("%d", (long) numericValue); + } else { + return String.valueOf(numericValue); + } + } + case BOOLEAN: + return String.valueOf(cell.getBooleanCellValue()); + case FORMULA: + // Evaluate formula and get the cached value as string + // Be cautious with formula evaluation as it can be complex + try { + return getCellValueAsString(cell.getSheet().getWorkbook().getCreationHelper().createFormulaEvaluator().evaluateInCell(cell)); + } catch (Exception e) { + // Fallback to cached formula result string if evaluation fails + LOGGER.warning("Could not evaluate formula in cell " + cell.getAddress() + ": " + e.getMessage()); + return cell.getCellFormula(); + } + case BLANK: + default: + return ""; + } + } + + protected List getRowValues(Row row) { + if (row == null) { + return new ArrayList<>(); + } + List values = new ArrayList<>(); + for (Cell cell : row) { + values.add(getCellValueAsString(cell)); + } + return values; + } + + protected Optional findHeaderRow(Sheet sheet, ExcelParserConfig config) { + // Basic implementation: Assumes first non-empty row is header. + // TODO: Enhance with config: config.getHeaderRowIndex() or auto-detect + for (Row row : sheet) { + if (row == null) continue; + boolean hasValues = false; + for (Cell cell : row) { + if (cell != null && cell.getCellType() != CellType.BLANK && StringUtils.isNotBlank(getCellValueAsString(cell))) { + hasValues = true; + break; + } + } + if (hasValues) { + return Optional.of(row.getRowNum()); + } + } + return Optional.empty(); + } + + protected List readHeader(Sheet sheet, int headerRowIndex) { + Row headerRow = sheet.getRow(headerRowIndex); + if (headerRow == null) { + return new ArrayList<>(); + } + return getRowValues(headerRow).stream().filter(StringUtils::isNotBlank).collect(Collectors.toList()); + } + + protected Optional findFirstDataRow(Sheet sheet, int headerRowIndex, ExcelParserConfig config) { + // Basic: Assumes data starts on the row immediately after the header. + // TODO: Enhance with config: config.getDataStartRowIndex() or auto-detect + int potentialFirstDataRow = headerRowIndex + 1; + if (potentialFirstDataRow <= sheet.getLastRowNum()) { + Row row = sheet.getRow(potentialFirstDataRow); + // Check if the row is not null and not entirely empty + if (row != null && !isRowEmpty(row)) { + return Optional.of(potentialFirstDataRow); + } + } + // Fallback: search for the next non-empty row after header + for (int i = headerRowIndex + 1; i <= sheet.getLastRowNum(); i++) { + Row dataRow = sheet.getRow(i); + if (dataRow != null && !isRowEmpty(dataRow)) { + return Optional.of(i); + } + } + return Optional.empty(); + } + + protected boolean isRowEmpty(Row row) { + if (row == null) { + return true; + } + // Check if all cells in the row are blank + for (Cell cell : row) { + if (cell != null && cell.getCellType() != CellType.BLANK && StringUtils.isNotBlank(getCellValueAsString(cell))) { + return false; // Found a non-empty cell + } + } + return true; // All cells are empty or null + } +} diff --git a/src/main/java/io/jenkins/plugins/reporter/parser/ExcelMultiReportParser.java b/src/main/java/io/jenkins/plugins/reporter/parser/ExcelMultiReportParser.java new file mode 100644 index 0000000..0deef73 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/reporter/parser/ExcelMultiReportParser.java @@ -0,0 +1,146 @@ +package io.jenkins.plugins.reporter.parser; + +import io.jenkins.plugins.reporter.model.ExcelParserConfig; +import io.jenkins.plugins.reporter.model.Item; +import io.jenkins.plugins.reporter.model.ReportDto; +import org.apache.poi.ss.usermodel.*; +// import org.apache.commons.lang3.StringUtils; // No longer directly used here +// import org.apache.commons.lang3.math.NumberUtils; // No longer directly used here +import org.apache.poi.ss.usermodel.WorkbookFactory; // Ensure this is present + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Optional; + +public class ExcelMultiReportParser extends BaseExcelParser { // Changed + + private static final long serialVersionUID = 456789012345L; // New UID + private final String id; + private List parserMessages; + private List overallHeader = null; + + public ExcelMultiReportParser(String id, ExcelParserConfig config) { // Changed + super(config); + this.id = id; + this.parserMessages = new ArrayList<>(); + } + + @Override + public ReportDto parse(File file) throws IOException { + this.overallHeader = null; + // this.parserMessages.clear(); // Clear if instance is reused; assume new instance for now. + + ReportDto aggregatedReport = new ReportDto(); + aggregatedReport.setId(this.id); + aggregatedReport.setItems(new ArrayList<>()); + + try (InputStream is = new FileInputStream(file); + Workbook workbook = WorkbookFactory.create(is)) { + + if (workbook.getNumberOfSheets() == 0) { + this.parserMessages.add("Excel file has no sheets: " + file.getName()); + LOGGER.warning("Excel file has no sheets: " + file.getName()); + aggregatedReport.setParserLogMessages(this.parserMessages); + return aggregatedReport; + } + + for (Sheet sheet : workbook) { + String cleanSheetName = sheet.getSheetName().replaceAll("[^a-zA-Z0-9_.-]", "_"); + ReportDto sheetReport = parseSheet(sheet, sheet.getSheetName(), this.config, this.id + "::" + cleanSheetName); + + if (sheetReport != null && sheetReport.getItems() != null) { + for (Item item : sheetReport.getItems()) { + if (aggregatedReport.getItems() == null) aggregatedReport.setItems(new java.util.ArrayList<>()); // Defensive + aggregatedReport.getItems().add(item); + } + } + } + + aggregatedReport.setParserLogMessages(this.parserMessages); + return aggregatedReport; + + } catch (Exception e) { + this.parserMessages.add("Error parsing Excel file " + file.getName() + ": " + e.getMessage()); + LOGGER.severe("Error parsing Excel file " + file.getName() + ": " + e.getMessage()); + aggregatedReport.setParserLogMessages(this.parserMessages); + return aggregatedReport; + } + } + + @Override + protected ReportDto parseSheet(Sheet sheet, String sheetName, ExcelParserConfig config, String reportId) { + ReportDto report = new ReportDto(); + report.setId(reportId); + report.setItems(new ArrayList<>()); + + Optional headerRowIndexOpt = findHeaderRow(sheet, config); + if (!headerRowIndexOpt.isPresent()) { + this.parserMessages.add(String.format("No header row found in sheet: '%s'", sheetName)); + LOGGER.warning(String.format("No header row found in sheet: '%s'", sheetName)); + return report; + } + int headerRowIndex = headerRowIndexOpt.get(); + + List currentSheetHeader = readHeader(sheet, headerRowIndex); + if (currentSheetHeader.isEmpty() || currentSheetHeader.size() < 2) { + this.parserMessages.add(String.format("Empty or insufficient header (found %d columns, requires at least 2) in sheet: '%s' at row %d. Skipping sheet.", currentSheetHeader.size(), sheetName, headerRowIndex + 1)); + LOGGER.warning(String.format("Empty or insufficient header in sheet: '%s' at row %d. Skipping sheet.", sheetName, headerRowIndex + 1)); + return report; + } + + // Column Consistency Check + if (this.overallHeader == null) { + this.overallHeader = new ArrayList<>(currentSheetHeader); // Set if this is the first valid header encountered + this.parserMessages.add(String.format("Info: Using header from sheet '%s' as the reference for column consistency: %s", sheetName, this.overallHeader.toString())); + } else { + if (!this.overallHeader.equals(currentSheetHeader)) { + String msg = String.format("Error: Sheet '%s' has an inconsistent header. Expected: %s, Found: %s. Skipping this sheet.", sheetName, this.overallHeader.toString(), currentSheetHeader.toString()); + this.parserMessages.add(msg); + LOGGER.severe(msg); + return report; + } + } + + Optional firstDataRowIndexOpt = findFirstDataRow(sheet, headerRowIndex, config); + if (!firstDataRowIndexOpt.isPresent()) { + this.parserMessages.add(String.format("No data rows found after header in sheet: '%s'", sheetName)); + LOGGER.info(String.format("No data rows found after header in sheet: '%s'", sheetName)); + return report; + } + int firstDataRowIndex = firstDataRowIndexOpt.get(); + + Row actualFirstDataRow = sheet.getRow(firstDataRowIndex); + List firstDataRowValues = null; + if (actualFirstDataRow != null && !isRowEmpty(actualFirstDataRow)) { + firstDataRowValues = getRowValues(actualFirstDataRow); + } + this.parserMessages.add(String.format("Debug [ExcelMulti]: Sheet: %s, Header: %s", sheetName, currentSheetHeader.toString())); + this.parserMessages.add(String.format("Debug [ExcelMulti]: Sheet: %s, FirstDataRowValues for structure detection: %s", sheetName, (firstDataRowValues != null ? firstDataRowValues.toString() : "null"))); + + int colIdxValueStart = detectColumnStructure(currentSheetHeader, firstDataRowValues, this.parserMessages, "ExcelMulti"); + this.parserMessages.add(String.format("Debug [ExcelMulti]: Sheet: %s, Detected colIdxValueStart: %d", sheetName, colIdxValueStart)); + if (colIdxValueStart == -1) { + // Error already logged by detectColumnStructure + return report; + } + + // Data Processing Loop + for (int i = firstDataRowIndex; i <= sheet.getLastRowNum(); i++) { + Row currentRow = sheet.getRow(i); + if (isRowEmpty(currentRow)) { // isRowEmpty is a protected method in BaseExcelParser + this.parserMessages.add(String.format("Info [ExcelMulti]: Skipped empty Excel row object at sheet row index %d.", i)); + continue; + } + List rowValues = getRowValues(currentRow); + // Add the existing diagnostic log from the previous step + this.parserMessages.add(String.format("Debug [ExcelMulti]: Sheet: %s, Row: %d, Processing rowValues: %s", sheetName, i, rowValues.toString())); + parseRowToItems(report, rowValues, currentSheetHeader, colIdxValueStart, reportId, this.parserMessages, "ExcelMulti", i); + } + return report; + } +} diff --git a/src/main/java/io/jenkins/plugins/reporter/parser/ExcelReportParser.java b/src/main/java/io/jenkins/plugins/reporter/parser/ExcelReportParser.java new file mode 100644 index 0000000..5238539 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/reporter/parser/ExcelReportParser.java @@ -0,0 +1,120 @@ +package io.jenkins.plugins.reporter.parser; + +import io.jenkins.plugins.reporter.model.ExcelParserConfig; +import io.jenkins.plugins.reporter.model.Item; +import io.jenkins.plugins.reporter.model.ReportDto; +import org.apache.poi.ss.usermodel.*; +// import org.apache.commons.lang3.StringUtils; // No longer directly used here as logic moved to base +// import org.apache.commons.lang3.math.NumberUtils; // No longer directly used here + +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Optional; +// Ensure WorkbookFactory is imported if used: +import org.apache.poi.ss.usermodel.WorkbookFactory; + + +public class ExcelReportParser extends BaseExcelParser { + + private static final long serialVersionUID = 923478237482L; + private final String id; + private List parserMessages; + + public ExcelReportParser(String id, ExcelParserConfig config) { + super(config); + this.id = id; + this.parserMessages = new ArrayList<>(); + } + + @Override + public ReportDto parse(File file) throws IOException { + ReportDto reportDto = new ReportDto(); + reportDto.setId(this.id); + reportDto.setItems(new ArrayList<>()); + + try (InputStream is = new FileInputStream(file); + Workbook workbook = WorkbookFactory.create(is)) { + + if (workbook.getNumberOfSheets() == 0) { + this.parserMessages.add("Excel file has no sheets: " + file.getName()); + LOGGER.warning("Excel file has no sheets: " + file.getName()); + reportDto.setParserLogMessages(this.parserMessages); + return reportDto; + } + + Sheet firstSheet = workbook.getSheetAt(0); + ReportDto sheetReport = parseSheet(firstSheet, firstSheet.getSheetName(), this.config, this.id); + sheetReport.setParserLogMessages(this.parserMessages); + return sheetReport; + + } catch (Exception e) { + this.parserMessages.add("Error parsing Excel file " + file.getName() + ": " + e.getMessage()); + LOGGER.severe("Error parsing Excel file " + file.getName() + ": " + e.getMessage()); + reportDto.setParserLogMessages(this.parserMessages); + return reportDto; + } + } + + @Override + protected ReportDto parseSheet(Sheet sheet, String sheetName, ExcelParserConfig config, String reportId) { + ReportDto report = new ReportDto(); + report.setId(reportId); + report.setItems(new ArrayList<>()); + + Optional headerRowIndexOpt = findHeaderRow(sheet, config); + if (!headerRowIndexOpt.isPresent()) { + this.parserMessages.add(String.format("No header row found in sheet: %s", sheetName)); + LOGGER.warning(String.format("No header row found in sheet: %s", sheetName)); + return report; + } + int headerRowIndex = headerRowIndexOpt.get(); + + List header = readHeader(sheet, headerRowIndex); + if (header.isEmpty() || header.size() < 2) { + this.parserMessages.add(String.format("Empty or insufficient header (found %d columns, requires at least 2) in sheet: %s at row %d", header.size(), sheetName, headerRowIndex + 1)); + LOGGER.warning(String.format("Empty or insufficient header in sheet: %s at row %d", sheetName, headerRowIndex + 1)); + return report; + } + + Optional firstDataRowIndexOpt = findFirstDataRow(sheet, headerRowIndex, config); + if (!firstDataRowIndexOpt.isPresent()) { + this.parserMessages.add(String.format("No data rows found after header in sheet: %s", sheetName)); + LOGGER.info(String.format("No data rows found after header in sheet: %s", sheetName)); + return report; + } + int firstDataRowIndex = firstDataRowIndexOpt.get(); + + Row actualFirstDataRow = sheet.getRow(firstDataRowIndex); + List firstDataRowValues = null; + if (actualFirstDataRow != null && !isRowEmpty(actualFirstDataRow)) { + firstDataRowValues = getRowValues(actualFirstDataRow); + } + this.parserMessages.add(String.format("Debug [Excel]: Sheet: %s, Header: %s", sheetName, header.toString())); + this.parserMessages.add(String.format("Debug [Excel]: Sheet: %s, FirstDataRowValues for structure detection: %s", sheetName, (firstDataRowValues != null ? firstDataRowValues.toString() : "null"))); + + int colIdxValueStart = detectColumnStructure(header, firstDataRowValues, this.parserMessages, "Excel"); + this.parserMessages.add(String.format("Debug [Excel]: Sheet: %s, Detected colIdxValueStart: %d", sheetName, colIdxValueStart)); + if (colIdxValueStart == -1) { + // Error already logged by detectColumnStructure + return report; + } + + for (int i = firstDataRowIndex; i <= sheet.getLastRowNum(); i++) { + Row currentRow = sheet.getRow(i); + if (isRowEmpty(currentRow)) { // isRowEmpty is a protected method in BaseExcelParser + this.parserMessages.add(String.format("Info [Excel]: Skipped empty Excel row object at sheet row index %d.", i)); + continue; + } + List rowValues = getRowValues(currentRow); + // Add the existing diagnostic log from the previous step + this.parserMessages.add(String.format("Debug [Excel]: Sheet: %s, Row: %d, Processing rowValues: %s", sheetName, i, rowValues.toString())); + parseRowToItems(report, rowValues, header, colIdxValueStart, reportId, this.parserMessages, "Excel", i); + } + return report; + } +} 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 4417152..ee3ca39 100644 --- a/src/main/java/io/jenkins/plugins/reporter/provider/Csv.java +++ b/src/main/java/io/jenkins/plugins/reporter/provider/Csv.java @@ -11,8 +11,9 @@ 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.parser.AbstractReportParserBase; import org.apache.commons.lang3.StringUtils; -import org.apache.commons.lang3.math.NumberUtils; +// import org.apache.commons.lang3.math.NumberUtils; // Already commented out or removed import org.jenkinsci.Symbol; import org.kohsuke.stapler.DataBoundConstructor; @@ -55,13 +56,13 @@ public Descriptor() { } } - public static class CsvCustomParser extends ReportParser { + public static class CsvCustomParser extends AbstractReportParserBase { // Changed superclass - private static final long serialVersionUID = -8689695008930386640L; + private static final long serialVersionUID = -8689695008930386640L; // Keep existing UID for now private final String id; - private List parserMessages; + private List parserMessages; // This will be used by AbstractReportParserBase methods public CsvCustomParser(String id) { super(); @@ -77,15 +78,19 @@ public String getId() { private char detectDelimiter(File file) throws IOException { // List of possible delimiters char[] delimiters = { ',', ';', '\t', '|' }; + String[] delimiterNames = { "Comma", "Semicolon", "Tab", "Pipe" }; int[] delimiterCounts = new int[delimiters.length]; // Read the lines of the file 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 linesToCheck = 10; // Number of lines to check int linesChecked = 0; String line; while ((line = reader.readLine()) != null && linesChecked < linesToCheck) { + if (StringUtils.isBlank(line)) { // Skip blank lines + continue; + } for (int i = 0; i < delimiters.length; i++) { delimiterCounts[i] += StringUtils.countMatches(line, delimiters[i]); } @@ -93,15 +98,39 @@ private char detectDelimiter(File file) throws IOException { } } - // Return the most frequent delimiter + // Determine the most frequent delimiter int maxCount = 0; - char detectedDelimiter = 0; + int detectedDelimiterIndex = -1; for (int i = 0; i < delimiters.length; i++) { if (delimiterCounts[i] > maxCount) { maxCount = delimiterCounts[i]; - detectedDelimiter = delimiters[i]; + detectedDelimiterIndex = i; } } + + char detectedDelimiter = (detectedDelimiterIndex != -1) ? delimiters[detectedDelimiterIndex] : ','; // Default to comma if none found + + if (detectedDelimiterIndex != -1) { + // Check for ambiguity + for (int i = 0; i < delimiters.length; i++) { + if (i == detectedDelimiterIndex) continue; + // Ambiguous if another delimiter's count is > 0, and difference is less than 20% of max count, + // and both counts are above a threshold (e.g., 5) + if (delimiterCounts[i] > 5 && maxCount > 5 && + (maxCount - delimiterCounts[i]) < (maxCount * 0.2)) { + this.parserMessages.add(String.format( + "Warning [CSV]: Ambiguous delimiter. %s count (%d) is very similar to %s count (%d). Using '%c'.", + delimiterNames[detectedDelimiterIndex], maxCount, + delimiterNames[i], delimiterCounts[i], + detectedDelimiter)); + break; // Log once for the first ambiguity found + } + } + this.parserMessages.add(String.format("Info [CSV]: Detected delimiter: '%c' (Name: %s, Count: %d)", + detectedDelimiter, delimiterNames[detectedDelimiterIndex], maxCount)); + } else { + this.parserMessages.add("Warning [CSV]: No clear delimiter found. Defaulting to comma ','. Parsing might be inaccurate."); + } return detectedDelimiter; } @@ -109,150 +138,130 @@ private char detectDelimiter(File file) throws IOException { @Override public ReportDto parse(File file) throws IOException { + this.parserMessages.clear(); // Clear messages for each new parse operation // Get delimiter char delimiter = detectDelimiter(file); final CsvMapper mapper = new CsvMapper(); - final CsvSchema schema = mapper.schemaFor(String[].class).withColumnSeparator(delimiter); + final CsvSchema schema = mapper.schemaFor(String[].class).withColumnSeparator(delimiter).withoutQuoteChar(); // Try without quote char initially mapper.enable(CsvParser.Feature.WRAP_AS_ARRAY); - mapper.enable(CsvParser.Feature.SKIP_EMPTY_LINES); + // mapper.enable(CsvParser.Feature.SKIP_EMPTY_LINES); // We will handle empty line skipping manually for logging + mapper.disable(CsvParser.Feature.SKIP_EMPTY_LINES); mapper.enable(CsvParser.Feature.ALLOW_TRAILING_COMMA); mapper.enable(CsvParser.Feature.INSERT_NULLS_FOR_MISSING_COLUMNS); mapper.enable(CsvParser.Feature.TRIM_SPACES); - - final MappingIterator> it = mapper.readerForListOf(String.class) - .with(schema) - .readValues(file); - + ReportDto report = new ReportDto(); report.setId(getId()); report.setItems(new ArrayList<>()); - 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)); + List header = null; + final int MAX_LINES_TO_SCAN_FOR_HEADER = 20; + int linesScannedForHeader = 0; + + MappingIterator> it = null; + try { + it = mapper.readerForListOf(String.class) + .with(schema) + .readValues(file); + } catch (Exception e) { + this.parserMessages.add("Error [CSV]: Failed to initialize CSV reader: " + e.getMessage()); + report.setParserLogMessages(this.parserMessages); + return report; } - /** 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); + while (it.hasNext() && linesScannedForHeader < MAX_LINES_TO_SCAN_FOR_HEADER) { + List currentRow; + long currentLineNumber = 0; + try { + currentLineNumber = it.getCurrentLocation() != null ? it.getCurrentLocation().getLineNr() : -1; + currentRow = it.next(); + } catch (Exception e) { + this.parserMessages.add(String.format("Error [CSV]: Could not read line %d: %s", currentLineNumber, e.getMessage())); + linesScannedForHeader++; // Count this as a scanned line + continue; + } - 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; - } - } + linesScannedForHeader++; + if (currentRow == null || currentRow.stream().allMatch(s -> s == null || s.isEmpty())) { + this.parserMessages.add(String.format("Info [CSV]: Skipped empty or null line at file line number: %d while searching for header.", currentLineNumber)); + continue; } + header = currentRow; + this.parserMessages.add(String.format("Info [CSV]: Using file line %d as header: %s", currentLineNumber, header.toString())); + 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); + if (header == null) { + this.parserMessages.add("Error [CSV]: No valid header row found after scanning " + linesScannedForHeader + " lines. Cannot parse file."); + report.setParserLogMessages(this.parserMessages); + return report; + } - /** 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 (header.size() < 2) { + this.parserMessages.add(String.format("Error [CSV]: Insufficient columns in header (found %d, requires at least 2). Header: %s", header.size(), header.toString())); + report.setParserLogMessages(this.parserMessages); + return report; + } + + final List> rows = new ArrayList<>(); + long linesReadForData = 0; + while(it.hasNext()) { // Collect all data rows first + linesReadForData++; + try { + List r = it.next(); + if (r != null) { + rows.add(r); + } else { + this.parserMessages.add(String.format("Info [CSV]: Encountered a null row object at data line %d, skipping.", linesReadForData)); } + } catch (Exception e) { + this.parserMessages.add(String.format("Error [CSV]: Failed to read data row at data line %d: %s. Skipping row.", linesReadForData, e.getMessage())); } - /** 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)); + } + + List firstActualDataRow = null; + for (List r : rows) { + // Check if row has any non-blank content, considering nulls from INSERT_NULLS_FOR_MISSING_COLUMNS + if (r.stream().anyMatch(s -> s != null && !s.isEmpty())) { + firstActualDataRow = r; + break; } } - // report.setParserLog(parserMessages); + + if (firstActualDataRow == null) { // All data rows are empty or no data rows at all + if (rows.isEmpty()) { + this.parserMessages.add("Info [CSV]: No data rows found after header."); + } else { + this.parserMessages.add("Info [CSV]: All data rows after header are empty or contain only blank fields. No structure to detect or items to parse."); + } + report.setParserLogMessages(this.parserMessages); + return report; + } + + int colIdxValueStart = detectColumnStructure(header, firstActualDataRow, this.parserMessages, "CSV"); + if (colIdxValueStart == -1) { + // Error logged by detectColumnStructure + report.setParserLogMessages(this.parserMessages); + return report; + } + + /** Parse all data rows */ + for (int rowIdx = 0; rowIdx < rows.size(); rowIdx++) { + List row = rows.get(rowIdx); + // Pass rowIdx as rowIndexForLog, it's 0-based index into the 'rows' list + parseRowToItems(report, row, header, colIdxValueStart, this.id, this.parserMessages, "CSV", rowIdx); + } + + // Final check if items were added, especially if all rows were skipped by parseRowToItems + if (report.getItems().isEmpty() && !rows.isEmpty() && + !rows.stream().allMatch(r -> r.stream().allMatch(s -> s==null || s.isEmpty())) ) { // if not all rows were completely blank initially + this.parserMessages.add("Warning [CSV]: No items were successfully parsed from data rows. Check data integrity and column structure detection logs."); + } + + report.setParserLogMessages(this.parserMessages); return report; } } diff --git a/src/main/java/io/jenkins/plugins/reporter/provider/ExcelMultiProvider.java b/src/main/java/io/jenkins/plugins/reporter/provider/ExcelMultiProvider.java new file mode 100644 index 0000000..b5b6558 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/reporter/provider/ExcelMultiProvider.java @@ -0,0 +1,50 @@ +package io.jenkins.plugins.reporter.provider; + +import hudson.Extension; +import io.jenkins.plugins.reporter.Messages; +import io.jenkins.plugins.reporter.model.ExcelParserConfig; +import io.jenkins.plugins.reporter.model.Provider; +import io.jenkins.plugins.reporter.model.ReportParser; +import io.jenkins.plugins.reporter.parser.ExcelMultiReportParser; // Changed +import org.jenkinsci.Symbol; +import org.kohsuke.stapler.DataBoundConstructor; +import org.kohsuke.stapler.DataBoundSetter; + +public class ExcelMultiProvider extends Provider { // Changed + + private static final long serialVersionUID = 345678901234L; // New UID + private static final String ID = "excelmulti"; // Changed + + private ExcelParserConfig excelParserConfig; + + @DataBoundConstructor + public ExcelMultiProvider() { // Changed + super(); + this.excelParserConfig = new ExcelParserConfig(); + } + + public ExcelParserConfig getExcelParserConfig() { + return excelParserConfig; + } + + @DataBoundSetter + public void setExcelParserConfig(ExcelParserConfig excelParserConfig) { + this.excelParserConfig = excelParserConfig; + } + + @Override + public ReportParser createParser() { + if (getActualId().equals(getDescriptor().getId())) { + throw new IllegalArgumentException(Messages.Provider_Error()); // Consider a specific message for excelmulti + } + return new ExcelMultiReportParser(getActualId(), getExcelParserConfig()); // Changed + } + + @Symbol(ID) + @Extension + public static class Descriptor extends Provider.ProviderDescriptor { + public Descriptor() { + super(ID); + } + } +} diff --git a/src/main/java/io/jenkins/plugins/reporter/provider/ExcelProvider.java b/src/main/java/io/jenkins/plugins/reporter/provider/ExcelProvider.java new file mode 100644 index 0000000..c649d4f --- /dev/null +++ b/src/main/java/io/jenkins/plugins/reporter/provider/ExcelProvider.java @@ -0,0 +1,50 @@ +package io.jenkins.plugins.reporter.provider; + +import hudson.Extension; +import io.jenkins.plugins.reporter.Messages; +import io.jenkins.plugins.reporter.model.ExcelParserConfig; +import io.jenkins.plugins.reporter.model.Provider; +import io.jenkins.plugins.reporter.model.ReportParser; +import io.jenkins.plugins.reporter.parser.ExcelReportParser; +import org.jenkinsci.Symbol; +import org.kohsuke.stapler.DataBoundConstructor; +import org.kohsuke.stapler.DataBoundSetter; + +public class ExcelProvider extends Provider { + + private static final long serialVersionUID = 834732487834L; + private static final String ID = "excel"; + + private ExcelParserConfig excelParserConfig; + + @DataBoundConstructor + public ExcelProvider() { + super(); + this.excelParserConfig = new ExcelParserConfig(); + } + + public ExcelParserConfig getExcelParserConfig() { + return excelParserConfig; + } + + @DataBoundSetter + public void setExcelParserConfig(ExcelParserConfig excelParserConfig) { + this.excelParserConfig = excelParserConfig; + } + + @Override + public ReportParser createParser() { + if (getActualId().equals(getDescriptor().getId())) { + throw new IllegalArgumentException(Messages.Provider_Error()); + } + return new ExcelReportParser(getActualId(), getExcelParserConfig()); + } + + @Symbol(ID) + @Extension + public static class Descriptor extends Provider.ProviderDescriptor { + public Descriptor() { + super(ID); + } + } +} diff --git a/src/test/java/io/jenkins/plugins/reporter/parser/ExcelMultiReportParserTest.java b/src/test/java/io/jenkins/plugins/reporter/parser/ExcelMultiReportParserTest.java new file mode 100644 index 0000000..8a0e652 --- /dev/null +++ b/src/test/java/io/jenkins/plugins/reporter/parser/ExcelMultiReportParserTest.java @@ -0,0 +1,254 @@ +package io.jenkins.plugins.reporter.parser; + +import io.jenkins.plugins.reporter.model.ExcelParserConfig; +import io.jenkins.plugins.reporter.model.Item; +import io.jenkins.plugins.reporter.model.ReportDto; +import org.apache.poi.ss.usermodel.*; +import org.apache.poi.xssf.usermodel.XSSFWorkbook; // For creating test workbooks +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.Path; +import java.nio.file.Files; // For Files.writeString in one of the tests +// import java.util.ArrayList; // Not directly used for declaration, List is used +import java.util.Arrays; +import java.util.List; // Correct import for List +// import java.util.Map; // Not directly used +import java.util.stream.Collectors; + +class ExcelMultiReportParserTest { + + private ExcelParserConfig defaultConfig; + @TempDir + Path tempDir; // JUnit 5 temporary directory + + @BeforeEach + void setUp() { + defaultConfig = new ExcelParserConfig(); + } + + private File getResourceFile(String fileName) throws URISyntaxException { + URL resource = getClass().getResource("/io/jenkins/plugins/reporter/provider/" + fileName); + if (resource == null) { + throw new IllegalArgumentException("Test resource file not found: " + fileName + + ". Ensure it is in src/test/resources/io/jenkins/plugins/reporter/provider/"); + } + return new File(resource.toURI()); + } + + // Helper to create a multi-sheet workbook from single-sheet files + private File createMultiSheetWorkbook(String outputFileName, List sheetResourceFiles, List sheetNames) throws IOException, URISyntaxException { + File outputFile = tempDir.resolve(outputFileName).toFile(); + try (XSSFWorkbook multiSheetWorkbook = new XSSFWorkbook()) { + for (int i = 0; i < sheetResourceFiles.size(); i++) { + File sheetFile = getResourceFile(sheetResourceFiles.get(i)); + String sheetName = sheetNames.get(i); + Sheet newSheet = multiSheetWorkbook.createSheet(sheetName); + + try (FileInputStream fis = new FileInputStream(sheetFile); + Workbook sourceSheetWorkbook = WorkbookFactory.create(fis)) { + Sheet sourceSheet = sourceSheetWorkbook.getSheetAt(0); + int rowNum = 0; + for (Row sourceRow : sourceSheet) { + Row newRow = newSheet.createRow(rowNum++); + int cellNum = 0; + for (Cell sourceCell : sourceRow) { + Cell newCell = newRow.createCell(cellNum++); + switch (sourceCell.getCellType()) { + case STRING: + newCell.setCellValue(sourceCell.getStringCellValue()); + break; + case NUMERIC: + if (DateUtil.isCellDateFormatted(sourceCell)) { + newCell.setCellValue(sourceCell.getDateCellValue()); + } else { + newCell.setCellValue(sourceCell.getNumericCellValue()); + } + break; + case BOOLEAN: + newCell.setCellValue(sourceCell.getBooleanCellValue()); + break; + case FORMULA: + newCell.setCellFormula(sourceCell.getCellFormula()); + break; + case BLANK: + break; + default: + // Potentially log or handle other types if necessary + break; + } + } + } + } + } + try (FileOutputStream fos = new FileOutputStream(outputFile)) { + multiSheetWorkbook.write(fos); + } + } + return outputFile; + } + + @Test + void testParseMultiSheetConsistentHeaders() throws IOException, URISyntaxException { + List sheetFiles = Arrays.asList( + "sample_excel_multi_consistent_sheet1_Data_Alpha.xlsx", + "sample_excel_multi_consistent_sheet2_Data_Beta.xlsx"); + List sheetNames = Arrays.asList("Data Alpha", "Data Beta"); + File multiSheetFile = createMultiSheetWorkbook("consistent_multi.xlsx", sheetFiles, sheetNames); + + ExcelMultiReportParser parser = new ExcelMultiReportParser("testMultiConsistent", defaultConfig); + ReportDto result = parser.parse(multiSheetFile); + + assertNotNull(result); + // System.out.println("Messages (Consistent): " + result.getParserLogMessages()); + + // Items from Data Alpha (ID, Metric, Result): Alpha001, Time, 100; Alpha002, Score, 200 + // Items from Data Beta (ID, Metric, Result): Beta001, Time, 110; Beta002, Score, 210 + // Report ID for parseSheet: "testMultiConsistent::Data_Alpha" and "testMultiConsistent::Data_Beta" + // Item ID structure: reportIdForSheet + "::" + hierarchyPart1 + "_" + hierarchyPart2 ... + // Example: "testMultiConsistent::Data_Alpha::Alpha001_Time" + + long totalExpectedItems = 2; // In ExcelMultiReportParser, items are aggregated under the main reportId, hierarchy ensures uniqueness + // The current parser implementation creates a flat list of items in the final ReportDto. + // Each item from parseSheet is added to aggregatedReport.getItems(). + // The ID generation in parseSheet is: currentItemCombinedId += cellValue... + // parentId starts as "report". + // For "Alpha001, Time, 100", item "Alpha001" is created. Then item "Time" is nested under "Alpha001". + // The "Result" (100) is attached to "Time". + // So, we expect "Alpha001" and "Alpha002" from sheet 1. + // And "Beta001" and "Beta002" from sheet 2. These are the top-level items in the final list. + assertEquals(totalExpectedItems, result.getItems().size(), "Should have 2 top-level items (Alpha001/Alpha002 and Beta001/Beta002 merged by hierarchy) in total from two sheets if hierarchy matches."); + // Let's re-evaluate the expected item count and structure. + // Sheet 1: Alpha001 (parent), Time (child, value 100), Score (child, value 200) -> No, this is wrong. + // The parser logic: "ID" is one hierarchy, "Metric" is another. "Result" is the value column. + // Sheet 1: Item "Alpha001" (id testMultiConsistent::Data_Alpha::Alpha001) + // -> Item "Time" (id testMultiConsistent::Data_Alpha::Alpha001_Time, result {"Result":100}) + // Item "Alpha002" (id testMultiConsistent::Data_Alpha::Alpha002) + // -> Item "Score" (id testMultiConsistent::Data_Alpha::Alpha002_Score, result {"Result":200}) + // Sheet 2: Item "Beta001" (id testMultiConsistent::Data_Beta::Beta001) + // -> Item "Time" (id testMultiConsistent::Data_Beta::Beta001_Time, result {"Result":110}) + // Item "Beta002" (id testMultiConsistent::Data_Beta::Beta002) + // -> Item "Score" (id testMultiConsistent::Data_Beta::Beta002_Score, result {"Result":210}) + // So, the top-level items in the aggregated report are Alpha001, Alpha002, Beta001, Beta002. That's 4. + assertEquals(4, result.getItems().size(), "Should have 4 top-level items in total from two sheets."); + + + Item itemA001 = result.findItem("testMultiConsistent::Data_Alpha::Alpha001", result.getItems()).orElse(null); + assertNotNull(itemA001, "Item Alpha001 from sheet 'Data Alpha' not found."); + assertEquals("Alpha001", itemA001.getName()); + Item itemA001Time = result.findItem("testMultiConsistent::Data_Alpha::Alpha001_Time", itemA001.getItems()).orElse(null); + assertNotNull(itemA001Time, "Sub-item Time for Alpha001 not found."); + assertEquals("Time", itemA001Time.getName()); + assertEquals(100, itemA001Time.getResult().get("Result")); + + Item itemB001 = result.findItem("testMultiConsistent::Data_Beta::Beta001", result.getItems()).orElse(null); + assertNotNull(itemB001, "Item Beta001 from sheet 'Data Beta' not found."); + assertEquals("Beta001", itemB001.getName()); + Item itemB001Time = result.findItem("testMultiConsistent::Data_Beta::Beta001_Time", itemB001.getItems()).orElse(null); + assertNotNull(itemB001Time, "Sub-item Time for Beta001 not found."); + assertEquals("Time", itemB001Time.getName()); + assertEquals(110, itemB001Time.getResult().get("Result")); + + assertTrue(result.getParserLogMessages().stream().anyMatch(m -> m.contains("Using header from sheet 'Data Alpha' as the reference")), "Should log reference header message."); + } + + @Test + void testParseMultiSheetInconsistentHeaders() throws IOException, URISyntaxException { + List sheetFiles = Arrays.asList( + "sample_excel_multi_inconsistent_header_sheet1_Metrics.xlsx", + "sample_excel_multi_inconsistent_header_sheet2_Stats.xlsx"); + List sheetNames = Arrays.asList("Metrics", "Stats"); // Sheet "Stats" has header: System, Disk, Network + File multiSheetFile = createMultiSheetWorkbook("inconsistent_multi.xlsx", sheetFiles, sheetNames); + + ExcelMultiReportParser parser = new ExcelMultiReportParser("testMultiInconsistent", defaultConfig); + ReportDto result = parser.parse(multiSheetFile); + + assertNotNull(result); + // System.out.println("Messages (Inconsistent): " + result.getParserLogMessages()); + + // Items from "Metrics" (System, CPU, Memory): SysA, 70, 500 + // Hierarchy is just "System". Values are "CPU", "Memory". + // Item ID: "testMultiInconsistent::Metrics::SysA" + // Results: {"CPU": 70, "Memory": 500} + assertEquals(1, result.getItems().size(), "Should only have items from the first sheet ('Metrics')."); + String itemSysA_ID = "testMultiInconsistent::Metrics::SysA"; + Item itemSysA = result.findItem(itemSysA_ID, result.getItems()).orElse(null); + assertNotNull(itemSysA, "Item from 'Metrics' sheet not found. ID searched: " + itemSysA_ID + + ". Available: " + result.getItems().stream().map(Item::getId).collect(Collectors.joining(", "))); + assertEquals("SysA", itemSysA.getName()); + assertEquals(70, itemSysA.getResult().get("CPU")); + assertEquals(500, itemSysA.getResult().get("Memory")); + + assertTrue(result.getParserLogMessages().stream().anyMatch(m -> m.contains("Error: Sheet 'Stats' has an inconsistent header.")), "Should log header inconsistency for 'Stats'."); + assertTrue(result.getParserLogMessages().stream().anyMatch(m -> m.contains("Skipping this sheet.")), "Should log skipping inconsistent sheet 'Stats'."); + } + + @Test + void testParseSingleSheetFileWithMultiParser() throws IOException, URISyntaxException { + ExcelMultiReportParser parser = new ExcelMultiReportParser("testSingleWithMulti", defaultConfig); + // sample_excel_single_sheet.xlsx has header: Category, SubCategory, Value1, Value2 + // Row: A, X, 10, 20 + File file = getResourceFile("sample_excel_single_sheet.xlsx"); + ReportDto result = parser.parse(file); + + assertNotNull(result); + // System.out.println("Messages (Single with Multi): " + result.getParserLogMessages().stream().collect(Collectors.joining("\n"))); + // System.out.println("Items (Single with Multi): " + result.getItems()); + + // Expected top-level items "A", "B" + assertEquals(2, result.getItems().size(), "Should be 2 top-level items (A, B)"); + + // ID structure: "testSingleWithMulti::Sheet1::A" + // Then sub-item "testSingleWithMulti::Sheet1::A_X" + Item itemA = result.findItem("testSingleWithMulti::Sheet1::A", result.getItems()).orElse(null); + assertNotNull(itemA, "Item A not found."); + + Item itemAX = result.findItem("testSingleWithMulti::Sheet1::A_X", itemA.getItems()).orElse(null); + assertNotNull(itemAX, "Item AX not found in A."); + assertEquals("X", itemAX.getName()); + assertEquals(10, itemAX.getResult().get("Value1")); + assertEquals(20, itemAX.getResult().get("Value2")); + + assertTrue(result.getParserLogMessages().stream().anyMatch(m -> m.contains("Using header from sheet 'Sheet1' as the reference")), "Should log reference header message for the single sheet."); + } + + @Test + void testParseEmptyExcelFile() throws IOException, URISyntaxException { + ExcelMultiReportParser parser = new ExcelMultiReportParser("testEmptyFileMulti", defaultConfig); + File file = getResourceFile("sample_excel_empty_sheet.xlsx"); + ReportDto result = parser.parse(file); + + assertNotNull(result); + assertTrue(result.getItems().isEmpty(), "Should have no items for an empty file/sheet."); + // System.out.println("Messages (Empty File Multi): " + result.getParserLogMessages()); + assertTrue(result.getParserLogMessages().stream().anyMatch(m -> m.toLowerCase().contains("no header row found in sheet 'sample_excel_empty_sheet.csv'")), "Should log no header for the sheet named after the source CSV. Message was: " + result.getParserLogMessages()); + } + + @Test + void testParseInvalidFileWithMultiParser() throws IOException { + ExcelMultiReportParser parser = new ExcelMultiReportParser("testInvalidMulti", defaultConfig); + Path tempFile = tempDir.resolve("dummy_multi.txt"); + Files.writeString(tempFile, "This is not an excel file for multi-parser."); + + ReportDto result = parser.parse(tempFile.toFile()); + + assertNotNull(result); + assertTrue(result.getItems().isEmpty(), "Should have no items for a non-Excel file."); + // System.out.println("Messages (Invalid Multi): " + result.getParserLogMessages()); + assertTrue(result.getParserLogMessages().stream() + .anyMatch(m -> m.toLowerCase().contains("error parsing excel file") || + m.toLowerCase().contains("your input appears to be a text file") || + m.toLowerCase().contains("invalid header signature") || + m.toLowerCase().contains("file format not supported")), + "Should log error about parsing or file format. Actual: " + result.getParserLogMessages()); + } +} diff --git a/src/test/java/io/jenkins/plugins/reporter/parser/ExcelReportParserTest.java b/src/test/java/io/jenkins/plugins/reporter/parser/ExcelReportParserTest.java new file mode 100644 index 0000000..a8e6213 --- /dev/null +++ b/src/test/java/io/jenkins/plugins/reporter/parser/ExcelReportParserTest.java @@ -0,0 +1,205 @@ +package io.jenkins.plugins.reporter.parser; + +import io.jenkins.plugins.reporter.model.ExcelParserConfig; +import io.jenkins.plugins.reporter.model.Item; +import io.jenkins.plugins.reporter.model.ReportDto; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +import java.io.File; +import java.io.IOException; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +// import java.nio.file.Paths; // Not used +import java.util.List; +// import java.util.stream.Collectors; // Not used + +class ExcelReportParserTest { + + private ExcelParserConfig defaultConfig; + + @BeforeEach + void setUp() { + defaultConfig = new ExcelParserConfig(); // Use default config for these tests + } + + private File getResourceFile(String fileName) throws URISyntaxException { + URL resource = getClass().getResource("/io/jenkins/plugins/reporter/provider/" + fileName); + if (resource == null) { + throw new IllegalArgumentException("Test resource file not found: " + fileName + ". Ensure it's in src/test/resources/io/jenkins/plugins/reporter/provider/"); + } + return new File(resource.toURI()); + } + + @Test + void testParseSingleSheetNominal() throws IOException, URISyntaxException { + ExcelReportParser parser = new ExcelReportParser("testReport1", defaultConfig); + File file = getResourceFile("sample_excel_single_sheet.xlsx"); + ReportDto result = parser.parse(file); + + assertNotNull(result); + assertEquals("testReport1", result.getId()); + assertFalse(result.getItems().isEmpty(), "Should have parsed items."); + // System.out.println("Parser messages (single_sheet): " + result.getParserLogMessages()); + // System.out.println("Items (single_sheet): " + result.getItems()); + + // Expected structure from sample_excel_single_sheet.xlsx: + // Header: Category, SubCategory, Value1, Value2 + // Row: A, X, 10, 20 + // Row: A, Y, 15, 25 + // Row: B, Z, 20, 30 + // ExcelReportParser will create IDs like "testReport1::A", "testReport1::A_X" + + assertEquals(2, result.getItems().size(), "Should be 2 top-level items (A, B)"); + + Item itemA = result.findItem("testReport1::A", result.getItems()).orElse(null); + assertNotNull(itemA, "Item A not found. Available top-level items: " + result.getItems().stream().map(Item::getId).collect(java.util.stream.Collectors.toList())); + assertEquals("A", itemA.getName()); + assertEquals(2, itemA.getItems().size(), "Item A should have 2 sub-items (X, Y)"); + + Item itemAX = result.findItem("testReport1::A_X", itemA.getItems()).orElse(null); + assertNotNull(itemAX, "Item AX not found in A. Available sub-items: " + itemA.getItems().stream().map(Item::getId).collect(java.util.stream.Collectors.toList())); + assertEquals("X", itemAX.getName()); + assertNotNull(itemAX.getResult(), "Item AX should have results."); + assertEquals(10, itemAX.getResult().get("Value1")); + assertEquals(20, itemAX.getResult().get("Value2")); + + Item itemAY = result.findItem("testReport1::A_Y", itemA.getItems()).orElse(null); + assertNotNull(itemAY, "Item AY not found in A."); + assertEquals("Y", itemAY.getName()); + assertNotNull(itemAY.getResult(), "Item AY should have results."); + assertEquals(15, itemAY.getResult().get("Value1")); + assertEquals(25, itemAY.getResult().get("Value2")); + + Item itemB = result.findItem("testReport1::B", result.getItems()).orElse(null); + assertNotNull(itemB, "Item B not found."); + assertEquals("B", itemB.getName()); + assertEquals(1, itemB.getItems().size(), "Item B should have 1 sub-item (Z)"); + + Item itemBZ = result.findItem("testReport1::B_Z", itemB.getItems()).orElse(null); + assertNotNull(itemBZ, "Item BZ not found in B."); + assertEquals("Z", itemBZ.getName()); + assertNotNull(itemBZ.getResult(), "Item BZ should have results."); + assertEquals(20, itemBZ.getResult().get("Value1")); + assertEquals(30, itemBZ.getResult().get("Value2")); + + // Check for specific messages if needed, e.g., about structure detection + // assertTrue(result.getParserLogMessages().stream().anyMatch(m -> m.contains("Detected structure in sheet"))); + } + + @Test + void testParseOnlyHeader() throws IOException, URISyntaxException { + ExcelReportParser parser = new ExcelReportParser("testOnlyHeader", defaultConfig); + File file = getResourceFile("sample_excel_only_header.xlsx"); + ReportDto result = parser.parse(file); + + assertNotNull(result); + assertTrue(result.getItems().isEmpty(), "Should have no items when only header is present."); + // System.out.println("Parser messages (only_header): " + result.getParserLogMessages()); + assertTrue(result.getParserLogMessages().stream() + .anyMatch(m -> m.toLowerCase().contains("no data rows found after header")), + "Should log message about no data rows. Messages: " + result.getParserLogMessages()); + } + + @Test + void testParseEmptySheet() throws IOException, URISyntaxException { + ExcelReportParser parser = new ExcelReportParser("testEmptySheet", defaultConfig); + File file = getResourceFile("sample_excel_empty_sheet.xlsx"); // This file is empty + ReportDto result = parser.parse(file); + + assertNotNull(result); + assertTrue(result.getItems().isEmpty(), "Should have no items for an empty sheet."); + // System.out.println("Parser messages (empty_sheet): " + result.getParserLogMessages()); + // The ExcelReportParser uses WorkbookFactory.create(is) which might throw for a 0KB file if it's not even a valid ZIP. + // If it's a valid ZIP (empty XLSX), POI might say "has no sheets". + // If BaseExcelParser.findHeaderRow is called on an empty sheet, it returns Optional.empty(). + // ExcelReportParser.parseSheet then logs "No header row found". + assertTrue(result.getParserLogMessages().stream() + .anyMatch(m -> m.toLowerCase().contains("no header row found") || + m.toLowerCase().contains("excel file has no sheets") || + m.toLowerCase().contains("error parsing excel file")), // More general catch + "Should log message about no header, no sheets, or parsing error. Messages: " + result.getParserLogMessages()); + } + + @Test + void testParseNoHeaderData() throws IOException, URISyntaxException { + ExcelReportParser parser = new ExcelReportParser("testNoHeader", defaultConfig); + // sample_excel_no_header.xlsx contains: + // 1,2,3 + // 4,5,6 + File file = getResourceFile("sample_excel_no_header.xlsx"); + ReportDto result = parser.parse(file); + + assertNotNull(result); + // System.out.println("Parser messages (no_header): " + result.getParserLogMessages()); + // System.out.println("Items (no_header): " + result.getItems()); + + // BaseExcelParser.findHeaderRow will pick the first non-empty row. So "1,2,3" becomes header. + // Header names: "1", "2", "3" + // Data row: "4,5,6" + // Structure detection: + // - '6' is numeric, colIdxValueStart becomes 2 (index of "3") + // - '5' is numeric, colIdxValueStart becomes 1 (index of "2") + // - '4' is numeric, colIdxValueStart becomes 0 (index of "1") + // So, all columns are treated as value columns. Hierarchy part is empty. + // This means items will be direct children of the report, named "Data Row X" by ExcelReportParser. + + assertFalse(result.getItems().isEmpty(), "Should parse items even if header is data-like."); + assertEquals(1, result.getItems().size(), "Should parse one main data item when first row is taken as header."); + + Item dataItem = result.getItems().get(0); + // Default name for rows that don't form hierarchy is "Data Row X (Sheet: Y)" + // The ID is generated like: "sheet_" + sheetName.replaceAll("[^a-zA-Z0-9]", "") + "_row_" + (i + 1) + "_" + reportId; + // For this test, reportId is "testNoHeader". Sheet name is probably "Sheet1". Row index i is 0 (first data row). + // String expectedId = "sheet_Sheet1_row_1_testNoHeader"; // This is an assumption on sheet name and row index logic + // assertEquals(expectedId, dataItem.getId()); // ID check can be fragile + assertTrue(dataItem.getName().startsWith("Data Row 1"), "Item name should be generic for data row."); + + assertNotNull(dataItem.getResult(), "Data item should have results."); + assertEquals(4, dataItem.getResult().get("1")); // Header "1" -> value 4 + assertEquals(5, dataItem.getResult().get("2")); // Header "2" -> value 5 + assertEquals(6, dataItem.getResult().get("3")); // Header "3" -> value 6 + + assertTrue(result.getParserLogMessages().stream() + .anyMatch(m -> m.contains("Detected structure in sheet")), + "Structure detection message should be present. Messages: " + result.getParserLogMessages()); + assertTrue(result.getParserLogMessages().stream() + .anyMatch(m -> m.contains("Info: Row 1 in sheet 'Sheet1' has all columns treated as values.")), + "Should log info about all columns treated as values. Messages: " + result.getParserLogMessages()); + } + + @Test + void testParseInvalidFile() throws IOException { + ExcelReportParser parser = new ExcelReportParser("testInvalid", defaultConfig); + + Path tempDir = null; + File dummyFile = null; + try { + tempDir = Files.createTempDirectory("test-excel-invalid"); + dummyFile = new File(tempDir.toFile(), "dummy.txt"); + Files.writeString(dummyFile.toPath(), "This is not an excel file, just plain text."); + + ReportDto result = parser.parse(dummyFile); + + assertNotNull(result); + assertTrue(result.getItems().isEmpty(), "Should have no items for a non-Excel file."); + // System.out.println("Parser messages (invalid_file): " + result.getParserLogMessages()); + assertTrue(result.getParserLogMessages().stream() + .anyMatch(m -> m.toLowerCase().contains("error parsing excel file") || + m.toLowerCase().contains("your input appears to be a text file") || // POI specific message for text + m.toLowerCase().contains("invalid header signature") || // POI specific for non-zip + m.toLowerCase().contains("file format not supported")), // General fallback + "Should log error about parsing or file format. Messages: " + result.getParserLogMessages()); + } finally { + if (dummyFile != null && dummyFile.exists()) { + dummyFile.delete(); + } + if (tempDir != null && Files.exists(tempDir)) { + Files.delete(tempDir); + } + } + } +} diff --git a/src/test/java/io/jenkins/plugins/reporter/provider/CsvCustomParserTest.java b/src/test/java/io/jenkins/plugins/reporter/provider/CsvCustomParserTest.java new file mode 100644 index 0000000..9cfaad1 --- /dev/null +++ b/src/test/java/io/jenkins/plugins/reporter/provider/CsvCustomParserTest.java @@ -0,0 +1,260 @@ +package io.jenkins.plugins.reporter.provider; + +import io.jenkins.plugins.reporter.model.Item; +import io.jenkins.plugins.reporter.model.ReportDto; +import org.junit.jupiter.api.Test; // Combined BeforeEach and Test from correct package +import org.junit.jupiter.api.BeforeEach; // Explicitly for clarity, though Test covers it + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.File; +import java.io.IOException; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +// import java.nio.file.Paths; // Not currently used +// import java.util.List; // Used via specific classes like ArrayList or via stream().collect() +// import java.util.Map; // Used via item.getResult() +import java.util.stream.Collectors; + + +class CsvCustomParserTest { + + // Csv.CsvCustomParser is a public static inner class, so we can instantiate it directly. + // private Csv csvProvider; // Not strictly needed if CsvCustomParser is static and public + + @BeforeEach + void setUp() { + // No setup needed here if we directly instantiate CsvCustomParser + } + + private File getResourceFile(String fileName) throws URISyntaxException { + URL resource = getClass().getResource("/io/jenkins/plugins/reporter/provider/" + fileName); + if (resource == null) { + throw new IllegalArgumentException("Test resource file not found: " + fileName + + ". Ensure it is in src/test/resources/io/jenkins/plugins/reporter/provider/"); + } + return new File(resource.toURI()); + } + + @Test + void testParseStandardCsv() throws IOException, URISyntaxException { + Csv.CsvCustomParser parser = new Csv.CsvCustomParser("standard"); + File file = getResourceFile("sample_csv_standard.csv"); // Host,CPU,RAM,Disk -> server1,75,16,500 + ReportDto result = parser.parse(file); + + assertNotNull(result); + assertEquals("standard", result.getId()); + assertFalse(result.getItems().isEmpty(), "Should parse items."); + // System.out.println("Messages (Standard CSV): " + result.getParserLogMessages()); + // System.out.println("Items (Standard CSV): " + result.getItems()); + + assertEquals(2, result.getItems().size()); + Item server1 = result.findItem("server1", result.getItems()).orElse(null); + assertNotNull(server1, "Item 'server1' not found. Found: " + result.getItems().stream().map(Item::getId).collect(Collectors.joining(", "))); + assertEquals("server1", server1.getName()); + assertEquals(75, server1.getResult().get("CPU")); + assertEquals(16, server1.getResult().get("RAM")); + assertEquals(500, server1.getResult().get("Disk")); + + Item server2 = result.findItem("server2", result.getItems()).orElse(null); + assertNotNull(server2, "Item 'server2' not found."); + assertEquals("server2", server2.getName()); + assertEquals(60, server2.getResult().get("CPU")); + assertEquals(32, server2.getResult().get("RAM")); + assertEquals(1000, server2.getResult().get("Disk")); + } + + @Test + void testParseSemicolonCsv() throws IOException, URISyntaxException { + Csv.CsvCustomParser parser = new Csv.CsvCustomParser("semicolon"); + File file = getResourceFile("sample_csv_semicolon.csv"); // Product;Version;Count -> AppA;1.0;150 + ReportDto result = parser.parse(file); + + assertNotNull(result); + // System.out.println("Messages (Semicolon CSV): " + result.getParserLogMessages()); + assertTrue(result.getParserLogMessages().stream().anyMatch(m -> m.contains("Detected delimiter: ';'")), "Should log detected delimiter ';'"); + assertEquals(2, result.getItems().size()); // AppA, AppB + + // Hierarchy: Product -> Version. Value: Count + Item appA = result.findItem("AppA", result.getItems()).orElse(null); + assertNotNull(appA, "Item 'AppA' not found. Found: " + result.getItems().stream().map(Item::getId).collect(Collectors.joining(", "))); + Item appAV1 = result.findItem("AppA1.0", appA.getItems()).orElse(null); // ID is "AppA" + "1.0" + assertNotNull(appAV1, "Item 'AppA1.0' not found in AppA. Found: " + appA.getItems().stream().map(Item::getId).collect(Collectors.joining(", "))); + assertEquals("1.0", appAV1.getName()); + assertEquals(150, appAV1.getResult().get("Count")); + } + + @Test + void testParseTabCsv() throws IOException, URISyntaxException { + Csv.CsvCustomParser parser = new Csv.CsvCustomParser("tab"); + File file = getResourceFile("sample_csv_tab.csv"); // Name Age City -> John 30 New York + ReportDto result = parser.parse(file); + + assertNotNull(result); + // System.out.println("Messages (Tab CSV): " + result.getParserLogMessages()); + // System.out.println("Items (Tab CSV): " + result.getItems()); + assertTrue(result.getParserLogMessages().stream().anyMatch(m -> m.contains("Detected delimiter: '\t'")), "Should log detected delimiter '\\t'"); + assertEquals(2, result.getItems().size()); // John, Jane + + // Hierarchy: Name. Values: Age, City + Item john = result.findItem("John", result.getItems()).orElse(null); + assertNotNull(john, "Item 'John' not found. Found: " + result.getItems().stream().map(Item::getId).collect(Collectors.joining(", "))); + assertEquals("John", john.getName()); + assertEquals(30, john.getResult().get("Age")); + assertEquals(0, john.getResult().get("City"), "Non-numeric 'City' in value part should result in 0, as per current CsvCustomParser int conversion."); + } + + @Test + void testParseLeadingEmptyLinesCsv() throws IOException, URISyntaxException { + Csv.CsvCustomParser parser = new Csv.CsvCustomParser("leadingEmpty"); + File file = getResourceFile("sample_csv_leading_empty_lines.csv"); // (Potentially empty lines) ID,Name,Value -> 1,Test,100 + ReportDto result = parser.parse(file); + + assertNotNull(result); + // System.out.println("Messages (Leading Empty): " + result.getParserLogMessages()); + // System.out.println("Items (Leading Empty): " + result.getItems()); + + // Refactored CsvParser: "ID" (1) is numeric -> colIdxValueStart=0. All values. Generic item names. + // Header: ID, Name, Value. Data: 1, Test, 100. + // Expect one generic item because the hierarchy part is empty. + assertEquals(2, result.getItems().size(), "Should have 2 generic items, one for each data row."); + + Item item1 = result.getItems().stream() + .filter(it -> it.getResult() != null && Integer.valueOf(1).equals(it.getResult().get("ID"))) + .findFirst().orElse(null); + assertNotNull(item1, "Item for ID 1 not found or 'ID' not in result."); + assertEquals("Test", item1.getResult().get("Name")); + assertEquals(100, item1.getResult().get("Value")); + // Check for a message indicating that the header was found after skipping lines, if applicable. + // or that structure was detected with colIdxValueStart = 0 + assertTrue(result.getParserLogMessages().stream().anyMatch(m -> m.contains("Info [CSV]: Detected data structure from data row index 0: Hierarchy/Text columns: 0 to -1, Value/Numeric columns: 0 to 2.") || m.contains("First column ('ID') in first data row (data index 0) is numeric.")), "Expected message about structure detection for colIdxValueStart=0."); + } + + @Test + void testParseNoNumericCsv() throws IOException, URISyntaxException { + Csv.CsvCustomParser parser = new Csv.CsvCustomParser("noNumeric"); + File file = getResourceFile("sample_csv_no_numeric.csv"); // ColA,ColB,ColC -> text1,text2,text3 + ReportDto result = parser.parse(file); + + assertNotNull(result); + // System.out.println("Messages (No Numeric): " + result.getParserLogMessages()); + // System.out.println("Items (No Numeric): " + result.getItems()); + + // Refactored: Assumes last column "ColC" for values. text3 -> 0 + assertEquals(2, result.getItems().size()); + Item itemText1 = result.findItem("text1", result.getItems()).orElse(null); + assertNotNull(itemText1); + Item itemText1_text2 = result.findItem("text1text2", itemText1.getItems()).orElse(null); + assertNotNull(itemText1_text2); + assertEquals("text2", itemText1_text2.getName()); + assertEquals(0, itemText1_text2.getResult().get("ColC")); + assertTrue(result.getParserLogMessages().stream().anyMatch(m -> m.contains("Warning [CSV]: No numeric columns auto-detected")), "Expected warning about no numeric columns."); + } + + @Test + void testParseOnlyValuesCsv() throws IOException, URISyntaxException { + Csv.CsvCustomParser parser = new Csv.CsvCustomParser("onlyValues"); + File file = getResourceFile("sample_csv_only_values.csv"); // Val1,Val2,Val3 -> 10,20,30 + ReportDto result = parser.parse(file); + + assertNotNull(result); + // System.out.println("Messages (Only Values): " + result.getParserLogMessages()); + // System.out.println("Items (Only Values): " + result.getItems()); + // colIdxValueStart should be 0. All columns are values. Generic items per row. + assertEquals(2, result.getItems().size()); + + Item row1Item = result.getItems().get(0); + assertNotNull(row1Item.getResult()); + assertEquals(10, row1Item.getResult().get("Val1")); + assertEquals(20, row1Item.getResult().get("Val2")); + assertEquals(30, row1Item.getResult().get("Val3")); + assertTrue(result.getParserLogMessages().stream().anyMatch(m -> m.contains("Info [CSV]: First column ('Val1') is numeric. Treating it as the first value column.")), "Should log correct message for first column numeric. Messages: " + result.getParserLogMessages()); + } + + @Test + void testParseMixedHierarchyValuesCsv() throws IOException, URISyntaxException { + Csv.CsvCustomParser parser = new Csv.CsvCustomParser("mixed"); + File file = getResourceFile("sample_csv_mixed_hierarchy_values.csv"); + ReportDto result = parser.parse(file); + assertNotNull(result); + // System.out.println("Messages (Mixed Hier): " + result.getParserLogMessages()); + // System.out.println("Items (Mixed Hier): " + result.getItems().stream().map(Item::getId).collect(Collectors.joining(", "))); + + assertEquals(2, result.getItems().size(), "Expected Alpha and Beta as top-level items."); + + Item alpha = result.findItem("Alpha", result.getItems()).orElse(null); + assertNotNull(alpha, "Item 'Alpha' not found."); + assertEquals(1, alpha.getItems().size(), "Alpha should have one sub-component: Auth"); + Item auth = result.findItem("AlphaAuth", alpha.getItems()).orElse(null); + assertNotNull(auth, "Item 'AlphaAuth' not found."); + assertEquals(2, auth.getItems().size(), "Auth should have two metrics: LoginTime, LogoutTime"); + + Item loginTime = result.findItem("AlphaAuthLoginTime", auth.getItems()).orElse(null); + assertNotNull(loginTime, "Item 'AlphaAuthLoginTime' not found."); + assertEquals("LoginTime", loginTime.getName()); + assertEquals(120, loginTime.getResult().get("Value")); + + Item beta = result.findItem("Beta", result.getItems()).orElse(null); + assertNotNull(beta, "Item 'Beta' not found."); + Item db = result.findItem("BetaDB", beta.getItems()).orElse(null); + assertNotNull(db, "Item 'BetaDB' not found."); + Item queryTime = result.findItem("BetaDBQueryTime", db.getItems()).orElse(null); + assertNotNull(queryTime, "Item 'BetaDBQueryTime' not found."); + assertEquals(80, queryTime.getResult().get("Value")); + } + + @Test + void testParseOnlyHeaderCsv() throws IOException, URISyntaxException { + Csv.CsvCustomParser parser = new Csv.CsvCustomParser("onlyHeader"); + // Assuming sample_csv_only_header.csv exists: ColA,ColB,ColC + // This file might not have been created in the previous subtask if it was specific to Excel. + // If it doesn't exist, this test will fail at getResourceFile. + // For now, we assume it exists or will be created. + // If not, we'd need to create it here: + // Path tempFile = tempDir.resolve("sample_csv_only_header.csv"); + // Files.writeString(tempFile, "ColA,ColB,ColC"); + // File file = tempFile.toFile(); + File file = getResourceFile("sample_csv_only_header.csv"); + ReportDto result = parser.parse(file); + + assertNotNull(result); + assertTrue(result.getItems().isEmpty(), "Should have no items when only header is present."); + // System.out.println("Messages (Only Header CSV): " + result.getParserLogMessages()); + assertTrue(result.getParserLogMessages().stream().anyMatch(m -> m.contains("No valid data rows found after header.")), "Should log no data rows. Msgs: " + result.getParserLogMessages()); + } + + @Test + void testParseEmptyCsv() throws IOException, URISyntaxException { + Csv.CsvCustomParser parser = new Csv.CsvCustomParser("emptyCsv"); + // Assume sample_csv_empty.csv is an empty file. + // Path tempFile = tempDir.resolve("sample_csv_empty.csv"); + // Files.writeString(tempFile, ""); // Create empty file + // File file = tempFile.toFile(); + File file = getResourceFile("sample_csv_empty.csv"); + ReportDto result = parser.parse(file); + + assertNotNull(result); + assertTrue(result.getItems().isEmpty(), "Should have no items for an empty CSV."); + // System.out.println("Messages (Empty CSV): " + result.getParserLogMessages()); + assertTrue(result.getParserLogMessages().stream().anyMatch(m -> m.contains("No valid header row found")), "Should log no header or no content. Msgs: " + result.getParserLogMessages()); + } + + @Test + void testParseNonCsvFile(@org.junit.jupiter.api.io.TempDir Path tempDir) throws IOException { // Added @TempDir here + Csv.CsvCustomParser parser = new Csv.CsvCustomParser("nonCsv"); + File nonCsvFile = Files.createFile(tempDir.resolve("test.txt")).toFile(); + Files.writeString(nonCsvFile.toPath(), "This is just a plain text file, not CSV."); + + ReportDto result = parser.parse(nonCsvFile); + + assertNotNull(result); + assertTrue(result.getItems().isEmpty(), "Should have no items for a non-CSV file."); + // System.out.println("Messages (Non-CSV): " + result.getParserLogMessages()); + // The parser might try to detect delimiter, fail or pick one, then fail to find header or data. + // Or Jackson's CsvMapper might throw an early error. + // The refactored code has a try-catch around MappingIterator creation. + assertTrue(result.getParserLogMessages().stream().anyMatch(m -> m.toLowerCase().contains("error") || m.toLowerCase().contains("failed")), "Should log an error. Msgs: " + result.getParserLogMessages()); + } +} diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/alpha.xlsx b/src/test/resources/io/jenkins/plugins/reporter/provider/alpha.xlsx new file mode 100644 index 0000000..aa79290 Binary files /dev/null and b/src/test/resources/io/jenkins/plugins/reporter/provider/alpha.xlsx differ diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/beta.xlsx b/src/test/resources/io/jenkins/plugins/reporter/provider/beta.xlsx new file mode 100644 index 0000000..24fcf17 Binary files /dev/null and b/src/test/resources/io/jenkins/plugins/reporter/provider/beta.xlsx differ diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_empty.csv b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_empty.csv new file mode 100644 index 0000000..e69de29 diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_leading_empty_lines.csv b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_leading_empty_lines.csv new file mode 100644 index 0000000..2c96d43 --- /dev/null +++ b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_leading_empty_lines.csv @@ -0,0 +1,3 @@ +ID,Name,Value +1,Test,100 +2,Sample,200 diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_mixed_hierarchy_values.csv b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_mixed_hierarchy_values.csv new file mode 100644 index 0000000..20072a5 --- /dev/null +++ b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_mixed_hierarchy_values.csv @@ -0,0 +1,5 @@ +System,Component,Metric,Value +Alpha,Auth,LoginTime,120 +Alpha,Auth,LogoutTime,30 +Beta,DB,QueryTime,80 +Beta,DB,Connections,15 diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_no_numeric.csv b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_no_numeric.csv new file mode 100644 index 0000000..34eb755 --- /dev/null +++ b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_no_numeric.csv @@ -0,0 +1,3 @@ +ColA,ColB,ColC +text1,text2,text3 +textA,textB,textC diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_only_header.csv b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_only_header.csv new file mode 100644 index 0000000..310e09e --- /dev/null +++ b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_only_header.csv @@ -0,0 +1 @@ +ColA,ColB,ColC diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_only_values.csv b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_only_values.csv new file mode 100644 index 0000000..330ee3c --- /dev/null +++ b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_only_values.csv @@ -0,0 +1,3 @@ +Val1,Val2,Val3 +10,20,30 +40,50,60 diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_semicolon.csv b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_semicolon.csv new file mode 100644 index 0000000..d753841 --- /dev/null +++ b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_semicolon.csv @@ -0,0 +1,3 @@ +Product;Version;Count +AppA;1.0;150 +AppB;2.1;200 diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_standard.csv b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_standard.csv new file mode 100644 index 0000000..15d7ac2 --- /dev/null +++ b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_standard.csv @@ -0,0 +1,3 @@ +Host,CPU,RAM,Disk +server1,75,16,500 +server2,60,32,1000 diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_tab.csv b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_tab.csv new file mode 100644 index 0000000..8a2f1ef --- /dev/null +++ b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_csv_tab.csv @@ -0,0 +1,3 @@ +Name Age City +John 30 New York +Jane 25 London diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_empty_sheet.csv b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_empty_sheet.csv new file mode 100644 index 0000000..e69de29 diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_empty_sheet.xlsx b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_empty_sheet.xlsx new file mode 100644 index 0000000..3e48c78 Binary files /dev/null and b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_empty_sheet.xlsx differ diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_consistent_sheet1_Data_Alpha.csv b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_consistent_sheet1_Data_Alpha.csv new file mode 100644 index 0000000..e602a3b --- /dev/null +++ b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_consistent_sheet1_Data_Alpha.csv @@ -0,0 +1,3 @@ +ID,Metric,Result +Alpha001,Time,100 +Alpha002,Score,200 diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_consistent_sheet1_Data_Alpha.xlsx b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_consistent_sheet1_Data_Alpha.xlsx new file mode 100644 index 0000000..bdae9f1 Binary files /dev/null and b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_consistent_sheet1_Data_Alpha.xlsx differ diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_consistent_sheet2_Data_Beta.csv b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_consistent_sheet2_Data_Beta.csv new file mode 100644 index 0000000..ebca9d1 --- /dev/null +++ b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_consistent_sheet2_Data_Beta.csv @@ -0,0 +1,3 @@ +ID,Metric,Result +Beta001,Time,110 +Beta002,Score,210 diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_consistent_sheet2_Data_Beta.xlsx b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_consistent_sheet2_Data_Beta.xlsx new file mode 100644 index 0000000..0d18c08 Binary files /dev/null and b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_consistent_sheet2_Data_Beta.xlsx differ diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_inconsistent_header_sheet1_Metrics.csv b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_inconsistent_header_sheet1_Metrics.csv new file mode 100644 index 0000000..46d1add --- /dev/null +++ b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_inconsistent_header_sheet1_Metrics.csv @@ -0,0 +1,2 @@ +System,CPU,Memory +SysA,70,500 diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_inconsistent_header_sheet1_Metrics.xlsx b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_inconsistent_header_sheet1_Metrics.xlsx new file mode 100644 index 0000000..6c49ac1 Binary files /dev/null and b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_inconsistent_header_sheet1_Metrics.xlsx differ diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_inconsistent_header_sheet2_Stats.csv b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_inconsistent_header_sheet2_Stats.csv new file mode 100644 index 0000000..33af5a5 --- /dev/null +++ b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_inconsistent_header_sheet2_Stats.csv @@ -0,0 +1,2 @@ +System,Disk,Network +SysA,300,1000 diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_inconsistent_header_sheet2_Stats.xlsx b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_inconsistent_header_sheet2_Stats.xlsx new file mode 100644 index 0000000..d893fac Binary files /dev/null and b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_multi_inconsistent_header_sheet2_Stats.xlsx differ diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_no_header.csv b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_no_header.csv new file mode 100644 index 0000000..da813b6 --- /dev/null +++ b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_no_header.csv @@ -0,0 +1,2 @@ +1,2,3 +4,5,6 diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_no_header.xlsx b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_no_header.xlsx new file mode 100644 index 0000000..a2a1ad1 Binary files /dev/null and b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_no_header.xlsx differ diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_only_header.csv b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_only_header.csv new file mode 100644 index 0000000..310e09e --- /dev/null +++ b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_only_header.csv @@ -0,0 +1 @@ +ColA,ColB,ColC diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_only_header.xlsx b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_only_header.xlsx new file mode 100644 index 0000000..5219833 Binary files /dev/null and b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_only_header.xlsx differ diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_single_sheet.csv b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_single_sheet.csv new file mode 100644 index 0000000..b00eb06 --- /dev/null +++ b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_single_sheet.csv @@ -0,0 +1,7 @@ +"","","","" +"","","","" +"Category","SubCategory","Value1","Value2" +"A","X","10","20" +"A","Y","15","25" +"","","","" +"B","Z","20","30" diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_single_sheet.xlsx b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_single_sheet.xlsx new file mode 100644 index 0000000..5eed717 Binary files /dev/null and b/src/test/resources/io/jenkins/plugins/reporter/provider/sample_excel_single_sheet.xlsx differ diff --git a/src/test/resources/io/jenkins/plugins/reporter/provider/temp_multi.gnumeric b/src/test/resources/io/jenkins/plugins/reporter/provider/temp_multi.gnumeric new file mode 100644 index 0000000..5539b6e Binary files /dev/null and b/src/test/resources/io/jenkins/plugins/reporter/provider/temp_multi.gnumeric differ