From db3a7262054a0cd76cd36865df335663979bb3cc Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Tue, 19 Aug 2025 14:43:52 +0000 Subject: [PATCH] feat: Add excel and excelMulti support This commit adds support for excel and excelMulti reports. It introduces a new `Tabular` abstract class to share code between the CSV, excel and excelMulti providers. The excel and excelMulti providers use Apache POI to parse excel files. The `TabularParser` has been modified to use the cell type to identify numeric values. I was unable to get the tests to pass. The `TabularParser` is not correctly identifying the numeric values in the Excel files. I have tried several approaches to fix this issue, including using `NumberUtils.isCreatable`, regular expressions, and `NumberUtils.isParsable`. None of these approaches have worked. I have also tried to debug the issue by adding logging statements to the code. My last attempt was to use the cell type to identify numeric values. I have modified the `TabularParser` to accept a list of cell types, and I have modified the `ExcelParser` to pass the cell types to the `TabularParser`. However, this has resulted in a compilation error. I have fixed the compilation error, but the tests are still failing. --- create_excel.py | 12 ++ create_excel_multi.py | 21 +++ create_excel_multi_inconsistent.py | 17 ++ pom.xml | 21 +++ .../plugins/reporter/provider/Csv.java | 150 +--------------- .../plugins/reporter/provider/Excel.java | 109 ++++++++++++ .../plugins/reporter/provider/ExcelMulti.java | 114 ++++++++++++ .../plugins/reporter/provider/Tabular.java | 163 ++++++++++++++++++ .../reporter/provider/ExcelMultiTest.java | 35 ++++ .../plugins/reporter/provider/ExcelTest.java | 27 +++ src/test/resources/test.xlsx | Bin 0 -> 5211 bytes src/test/resources/test_multi.xlsx | Bin 0 -> 5735 bytes .../resources/test_multi_inconsistent.xlsx | Bin 0 -> 5706 bytes 13 files changed, 524 insertions(+), 145 deletions(-) create mode 100644 create_excel.py create mode 100644 create_excel_multi.py create mode 100644 create_excel_multi_inconsistent.py create mode 100644 src/main/java/io/jenkins/plugins/reporter/provider/Excel.java create mode 100644 src/main/java/io/jenkins/plugins/reporter/provider/ExcelMulti.java create mode 100644 src/main/java/io/jenkins/plugins/reporter/provider/Tabular.java create mode 100644 src/test/java/io/jenkins/plugins/reporter/provider/ExcelMultiTest.java create mode 100644 src/test/java/io/jenkins/plugins/reporter/provider/ExcelTest.java create mode 100644 src/test/resources/test.xlsx create mode 100644 src/test/resources/test_multi.xlsx create mode 100644 src/test/resources/test_multi_inconsistent.xlsx diff --git a/create_excel.py b/create_excel.py new file mode 100644 index 00000000..6a1565f8 --- /dev/null +++ b/create_excel.py @@ -0,0 +1,12 @@ +import openpyxl + +workbook = openpyxl.Workbook() +sheet = workbook.active +sheet.title = "Sheet1" +sheet.cell(row=1, column=1, value="ID") +sheet.cell(row=1, column=2, value="Value") +sheet.cell(row=2, column=1, value="1") +sheet.cell(row=2, column=2, value="10") +sheet.cell(row=3, column=1, value="2") +sheet.cell(row=3, column=2, value="20") +workbook.save("src/test/resources/test.xlsx") diff --git a/create_excel_multi.py b/create_excel_multi.py new file mode 100644 index 00000000..5d08e665 --- /dev/null +++ b/create_excel_multi.py @@ -0,0 +1,21 @@ +import openpyxl + +workbook = openpyxl.Workbook() +sheet1 = workbook.active +sheet1.title = "Sheet1" +sheet1.cell(row=1, column=1, value="ID") +sheet1.cell(row=1, column=2, value="Value") +sheet1.cell(row=2, column=1, value="1") +sheet1.cell(row=2, column=2, value="10") +sheet1.cell(row=3, column=1, value="2") +sheet1.cell(row=3, column=2, value="20") + +sheet2 = workbook.create_sheet("Sheet2") +sheet2.cell(row=1, column=1, value="ID") +sheet2.cell(row=1, column=2, value="Value") +sheet2.cell(row=2, column=1, value="3") +sheet2.cell(row=2, column=2, value="30") +sheet2.cell(row=3, column=1, value="4") +sheet2.cell(row=3, column=2, value="40") + +workbook.save("src/test/resources/test_multi.xlsx") diff --git a/create_excel_multi_inconsistent.py b/create_excel_multi_inconsistent.py new file mode 100644 index 00000000..dcdbfcf0 --- /dev/null +++ b/create_excel_multi_inconsistent.py @@ -0,0 +1,17 @@ +import openpyxl + +workbook = openpyxl.Workbook() +sheet1 = workbook.active +sheet1.title = "Sheet1" +sheet1.cell(row=1, column=1, value="ID") +sheet1.cell(row=1, column=2, value="Value") +sheet1.cell(row=2, column=1, value="1") +sheet1.cell(row=2, column=2, value="10") + +sheet2 = workbook.create_sheet("Sheet2") +sheet2.cell(row=1, column=1, value="ID") +sheet2.cell(row=1, column=2, value="Value2") +sheet2.cell(row=2, column=1, value="3") +sheet2.cell(row=2, column=2, value="30") + +workbook.save("src/test/resources/test_multi_inconsistent.xlsx") diff --git a/pom.xml b/pom.xml index aba68c38..2b925031 100644 --- a/pom.xml +++ b/pom.xml @@ -116,6 +116,21 @@ org.jenkins-ci.plugins jackson2-api + + org.apache.poi + poi + 5.2.3 + + + org.apache.poi + poi-ooxml + 5.2.3 + + + org.apache.xmlbeans + xmlbeans + 5.1.1 + @@ -156,6 +171,12 @@ tests test + + org.assertj + assertj-core + 3.23.1 + test + diff --git a/src/main/java/io/jenkins/plugins/reporter/provider/Csv.java b/src/main/java/io/jenkins/plugins/reporter/provider/Csv.java index 4417152d..d5ba66c0 100644 --- a/src/main/java/io/jenkins/plugins/reporter/provider/Csv.java +++ b/src/main/java/io/jenkins/plugins/reporter/provider/Csv.java @@ -7,12 +7,9 @@ import hudson.Extension; import io.jenkins.plugins.reporter.Messages; -import io.jenkins.plugins.reporter.model.Item; -import io.jenkins.plugins.reporter.model.Provider; import io.jenkins.plugins.reporter.model.ReportDto; import io.jenkins.plugins.reporter.model.ReportParser; import org.apache.commons.lang3.StringUtils; -import org.apache.commons.lang3.math.NumberUtils; import org.jenkinsci.Symbol; import org.kohsuke.stapler.DataBoundConstructor; @@ -24,7 +21,7 @@ import java.io.InputStreamReader; import java.nio.charset.StandardCharsets; -public class Csv extends Provider { +public class Csv extends Tabular { private static final long serialVersionUID = 9141170397250309265L; @@ -48,31 +45,20 @@ public ReportParser createParser() { /** Descriptor for this provider. */ @Symbol("csv") @Extension - public static class Descriptor extends Provider.ProviderDescriptor { + public static class Descriptor extends ProviderDescriptor { /** Creates the descriptor instance. */ public Descriptor() { super(ID); } } - public static class CsvCustomParser extends ReportParser { + public static class CsvCustomParser extends Tabular.TabularParser { private static final long serialVersionUID = -8689695008930386640L; - private final String id; - - private List parserMessages; - public CsvCustomParser(String id) { - super(); - this.id = id; - this.parserMessages = new ArrayList(); - } - - public String getId() { - return id; + super(id); } - private char detectDelimiter(File file) throws IOException { // List of possible delimiters @@ -106,7 +92,6 @@ private char detectDelimiter(File file) throws IOException { return detectedDelimiter; } - @Override public ReportDto parse(File file) throws IOException { // Get delimiter @@ -125,135 +110,10 @@ public ReportDto parse(File file) throws IOException { .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)); - } - - /** Parse all data rows */ - for (int rowIdx = 0; rowIdx < rowCount; rowIdx++) { - String parentId = "report"; - List row = rows.get(rowIdx); - Item last = null; - boolean lastItemAdded = false; - LinkedHashMap result = new LinkedHashMap<>(); - boolean emptyFieldFound = false; - int rowSize = row.size(); - - /** Parse untill first data line is found to get data and value field */ - if (colIdxValueStart == 0) { - /** Col 0 is assumed to be string */ - for (int colIdx = rowSize - 1; colIdx > 1; colIdx--) { - String value = row.get(colIdx); - - if (NumberUtils.isCreatable(value)) { - colIdxValueStart = colIdx; - } else { - if (colIdxValueStart > 0) { - parserMessages - .add(String.format("Found data - fields number = %d - numeric fields = %d", - colIdxValueStart, rowSize - colIdxValueStart)); - } - break; - } - } - } - - String valueId = ""; - /** Parse line if first data line is OK and line has more element than header */ - if ((colIdxValueStart > 0) && (rowSize >= headerColumnCount)) { - /** Check line and header size matching */ - for (int colIdx = 0; colIdx < headerColumnCount; colIdx++) { - String id = header.get(colIdx); - String value = row.get(colIdx); - - /** Check value fields */ - if ((colIdx < colIdxValueStart)) { - /** Test if text item is a value or empty */ - if ((NumberUtils.isCreatable(value)) || (StringUtils.isBlank(value))) { - /** Empty field found - message */ - if (colIdx == 0) { - parserMessages - .add(String.format("skipped line %d - First column item empty - col = %d ", - rowIdx + 2, colIdx + 1)); - break; - } else { - emptyFieldFound = true; - /** Continue next column parsing */ - continue; - } - } else { - /** Check if field values are present after empty cells */ - if (emptyFieldFound) { - parserMessages.add(String.format("skipped line %d Empty field in col = %d ", - rowIdx + 2, colIdx + 1)); - break; - } - } - valueId += value; - Optional parent = report.findItem(parentId, report.getItems()); - Item item = new Item(); - lastItemAdded = false; - item.setId(valueId); - item.setName(value); - String finalValueId = valueId; - if (parent.isPresent()) { - Item p = parent.get(); - if (!p.hasItems()) { - p.setItems(new ArrayList<>()); - } - if (p.getItems().stream().noneMatch(i -> i.getId().equals(finalValueId))) { - p.addItem(item); - lastItemAdded = true; - } - } else { - if (report.getItems().stream().noneMatch(i -> i.getId().equals(finalValueId))) { - report.getItems().add(item); - lastItemAdded = true; - } - } - parentId = valueId; - last = item; - } else { - Number val = 0; - if (NumberUtils.isCreatable(value)) { - val = NumberUtils.createNumber(value); - } - result.put(id, val.intValue()); - } - } - } else { - /** Skip file if first data line has no value field */ - if (colIdxValueStart == 0) { - parserMessages.add(String.format("skipped line %d - First data row not found", rowIdx + 2)); - continue; - } else { - parserMessages - .add(String.format("skipped line %d - line has fewer element than title", rowIdx + 2)); - continue; - } - } - /** If last item was created, it will be added to report */ - if (lastItemAdded) { - last.setResult(result); - } else { - parserMessages.add(String.format("ignored line %d - Same fields already exists", rowIdx + 2)); - } - } - // report.setParserLog(parserMessages); - return report; + return parse(header, rows); } } } \ No newline at end of file diff --git a/src/main/java/io/jenkins/plugins/reporter/provider/Excel.java b/src/main/java/io/jenkins/plugins/reporter/provider/Excel.java new file mode 100644 index 00000000..9fc0ffe8 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/reporter/provider/Excel.java @@ -0,0 +1,109 @@ +package io.jenkins.plugins.reporter.provider; + +import hudson.Extension; +import io.jenkins.plugins.reporter.Messages; +import io.jenkins.plugins.reporter.model.ReportDto; +import io.jenkins.plugins.reporter.model.ReportParser; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.usermodel.WorkbookFactory; +import org.jenkinsci.Symbol; +import org.kohsuke.stapler.DataBoundConstructor; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; + +public class Excel extends Tabular { + + private static final long serialVersionUID = 1L; + + private static final String ID = "excel"; + + @DataBoundConstructor + public Excel() { + super(); + // empty constructor required for stapler + } + + @Override + public ReportParser createParser() { + return new ExcelParser(getActualId()); + } + + /** Descriptor for this provider. */ + @Symbol("excel") + @Extension + public static class Descriptor extends ProviderDescriptor { + /** Creates the descriptor instance. */ + public Descriptor() { + super(ID); + } + } + + public static class ExcelParser extends Tabular.TabularParser { + + private static final long serialVersionUID = 1L; + + public ExcelParser(String id) { + super(id); + } + + @Override + public ReportDto parse(File file) throws IOException { + try (Workbook workbook = WorkbookFactory.create(file)) { + Sheet sheet = workbook.getSheetAt(0); + List> data = new ArrayList<>(); + for (Row row : sheet) { + List rowData = new ArrayList<>(); + for (Cell cell : row) { + switch (cell.getCellType()) { + case STRING: + rowData.add(cell.getRichStringCellValue().getString()); + break; + case NUMERIC: + if (org.apache.poi.ss.usermodel.DateUtil.isCellDateFormatted(cell)) { + rowData.add(cell.getDateCellValue().toString()); + } else { + rowData.add(String.valueOf(cell.getNumericCellValue())); + } + break; + case BOOLEAN: + rowData.add(String.valueOf(cell.getBooleanCellValue())); + break; + case FORMULA: + rowData.add(cell.getCellFormula()); + break; + default: + rowData.add(null); + } + } + data.add(rowData); + } + + if (data.isEmpty()) { + return new ReportDto(); + } + + List header = data.get(0); + List> rows = data.subList(1, data.size()); + List> cellTypes = new ArrayList<>(); + + for (Row row : sheet) { + List rowCellTypes = new ArrayList<>(); + for (Cell cell : row) { + rowCellTypes.add(cell.getCellType().getCode()); + } + cellTypes.add(rowCellTypes); + } + + return parse(header, rows, cellTypes); + } + } + } +} diff --git a/src/main/java/io/jenkins/plugins/reporter/provider/ExcelMulti.java b/src/main/java/io/jenkins/plugins/reporter/provider/ExcelMulti.java new file mode 100644 index 00000000..5ad7a8bb --- /dev/null +++ b/src/main/java/io/jenkins/plugins/reporter/provider/ExcelMulti.java @@ -0,0 +1,114 @@ +package io.jenkins.plugins.reporter.provider; + +import hudson.Extension; +import io.jenkins.plugins.reporter.Messages; +import io.jenkins.plugins.reporter.model.ReportDto; +import io.jenkins.plugins.reporter.model.ReportParser; +import org.apache.poi.ss.usermodel.Cell; +import org.apache.poi.ss.usermodel.Row; +import org.apache.poi.ss.usermodel.Sheet; +import org.apache.poi.ss.usermodel.Workbook; +import org.apache.poi.ss.usermodel.WorkbookFactory; +import org.jenkinsci.Symbol; +import org.kohsuke.stapler.DataBoundConstructor; + +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; +import java.util.stream.StreamSupport; + +public class ExcelMulti extends Tabular { + + private static final long serialVersionUID = 1L; + + private static final String ID = "excelMulti"; + + @DataBoundConstructor + public ExcelMulti() { + super(); + // empty constructor required for stapler + } + + @Override + public ReportParser createParser() { + return new ExcelMultiParser(getActualId()); + } + + /** Descriptor for this provider. */ + @Symbol("excelMulti") + @Extension + public static class Descriptor extends ProviderDescriptor { + /** Creates the descriptor instance. */ + public Descriptor() { + super(ID); + } + } + + public static class ExcelMultiParser extends Tabular.TabularParser { + + private static final long serialVersionUID = 1L; + + public ExcelMultiParser(String id) { + super(id); + } + + @Override + public ReportDto parse(File file) throws IOException { + try (Workbook workbook = WorkbookFactory.create(file)) { + List> allRows = new ArrayList<>(); + List header = null; + + for (Sheet sheet : workbook) { + List> sheetData = new ArrayList<>(); + for (Row row : sheet) { + List rowData = new ArrayList<>(); + for (Cell cell : row) { + switch (cell.getCellType()) { + case STRING: + rowData.add(cell.getRichStringCellValue().getString()); + break; + case NUMERIC: + if (org.apache.poi.ss.usermodel.DateUtil.isCellDateFormatted(cell)) { + rowData.add(cell.getDateCellValue().toString()); + } else { + rowData.add(String.valueOf(cell.getNumericCellValue())); + } + break; + case BOOLEAN: + rowData.add(String.valueOf(cell.getBooleanCellValue())); + break; + case FORMULA: + rowData.add(cell.getCellFormula()); + break; + default: + rowData.add(null); + } + } + sheetData.add(rowData); + } + + if (!sheetData.isEmpty()) { + if (header == null) { + header = sheetData.get(0); + allRows.addAll(sheetData.subList(1, sheetData.size())); + } else { + List currentHeader = sheetData.get(0); + if (!header.equals(currentHeader)) { + throw new IOException("Headers are not consistent across sheets"); + } + allRows.addAll(sheetData.subList(1, sheetData.size())); + } + } + } + + if (header == null) { + return new ReportDto(); + } + + return parse(header, allRows); + } + } + } +} diff --git a/src/main/java/io/jenkins/plugins/reporter/provider/Tabular.java b/src/main/java/io/jenkins/plugins/reporter/provider/Tabular.java new file mode 100644 index 00000000..f6fcaf48 --- /dev/null +++ b/src/main/java/io/jenkins/plugins/reporter/provider/Tabular.java @@ -0,0 +1,163 @@ +package io.jenkins.plugins.reporter.provider; + +import io.jenkins.plugins.reporter.model.Item; +import io.jenkins.plugins.reporter.model.Provider; +import io.jenkins.plugins.reporter.model.ReportDto; +import io.jenkins.plugins.reporter.model.ReportParser; +import 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; + +public abstract class Tabular extends Provider { + + private static final long serialVersionUID = 2427895324546453L; + + public static abstract class TabularParser extends ReportParser { + + private static final long serialVersionUID = -8689695008930386640L; + + private final String id; + + private List parserMessages; + + public TabularParser(String id) { + super(); + this.id = id; + this.parserMessages = new ArrayList(); + } + + public String getId() { + return id; + } + + public ReportDto parse(List header, List> rows) { + return parse(header, rows, null); + } + + public ReportDto parse(List header, List> rows, List> cellTypes) { + ReportDto report = new ReportDto(); + report.setId(getId()); + report.setItems(new ArrayList<>()); + + int rowCount = 0; + final int headerColumnCount = header.size(); + + if (headerColumnCount >= 2) { + rowCount = rows.size(); + } else { + parserMessages.add(String.format("skipped file - First line has %d elements", headerColumnCount + 1)); + } + + /** Parse all data rows */ + for (int rowIdx = 0; rowIdx < rowCount; rowIdx++) { + String parentId = "report"; + List row = rows.get(rowIdx); + Item last = null; + boolean lastItemAdded = false; + LinkedHashMap result = new LinkedHashMap<>(); + boolean emptyFieldFound = false; + int rowSize = row.size(); + int colIdxValueStart = 0; + + /** 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 >= 0; colIdx--) { + if (cellTypes != null && cellTypes.get(rowIdx + 1).get(colIdx) == 0) { + colIdxValueStart = colIdx; + } else if (colIdxValueStart > 0) { + break; + } + } + } + + String valueId = ""; + /** Parse line if first data line is OK and line has more element than header */ + if ((colIdxValueStart > 0) && (rowSize >= headerColumnCount)) { + /** Check line and header size matching */ + for (int colIdx = 0; colIdx < headerColumnCount; colIdx++) { + String id = header.get(colIdx); + String value = row.get(colIdx); + + /** Check value fields */ + if ((colIdx < colIdxValueStart)) { + /** Test if text item is a value or empty */ + if ((NumberUtils.isCreatable(value)) || (StringUtils.isBlank(value))) { + /** Empty field found - message */ + if (colIdx == 0) { + parserMessages + .add(String.format("skipped line %d - First column item empty - col = %d ", + rowIdx + 2, colIdx + 1)); + break; + } else { + emptyFieldFound = true; + /** Continue next column parsing */ + continue; + } + } else { + /** Check if field values are present after empty cells */ + if (emptyFieldFound) { + parserMessages.add(String.format("skipped line %d Empty field in col = %d ", + rowIdx + 2, colIdx + 1)); + break; + } + } + valueId += value; + Optional parent = report.findItem(parentId, report.getItems()); + Item item = new Item(); + lastItemAdded = false; + item.setId(valueId); + item.setName(value); + String finalValueId = valueId; + if (parent.isPresent()) { + Item p = parent.get(); + if (!p.hasItems()) { + p.setItems(new ArrayList<>()); + } + if (p.getItems().stream().noneMatch(i -> i.getId().equals(finalValueId))) { + p.addItem(item); + lastItemAdded = true; + } + } else { + if (report.getItems().stream().noneMatch(i -> i.getId().equals(finalValueId))) { + report.getItems().add(item); + lastItemAdded = true; + } + } + parentId = valueId; + last = item; + } else { + Number val = 0; + if (NumberUtils.isCreatable(value)) { + val = NumberUtils.createNumber(value); + } + result.put(id, val.intValue()); + } + } + } else { + /** Skip file if first data line has no value field */ + if (colIdxValueStart == 0) { + parserMessages.add(String.format("skipped line %d - First data row not found", rowIdx + 2)); + continue; + } else { + parserMessages + .add(String.format("skipped line %d - line has fewer element than title", rowIdx + 2)); + continue; + } + } + /** If last item was created, it will be added to report */ + if (lastItemAdded) { + last.setResult(result); + } else { + parserMessages.add(String.format("ignored line %d - Same fields already exists", rowIdx + 2)); + } + } + // report.setParserLog(parserMessages); + return report; + } + } +} diff --git a/src/test/java/io/jenkins/plugins/reporter/provider/ExcelMultiTest.java b/src/test/java/io/jenkins/plugins/reporter/provider/ExcelMultiTest.java new file mode 100644 index 00000000..2731adb1 --- /dev/null +++ b/src/test/java/io/jenkins/plugins/reporter/provider/ExcelMultiTest.java @@ -0,0 +1,35 @@ +package io.jenkins.plugins.reporter.provider; + +import io.jenkins.plugins.reporter.model.ReportDto; +import org.junit.Test; + +import java.io.File; +import java.io.IOException; + +import static org.assertj.core.api.Assertions.assertThat; + +public class ExcelMultiTest { + + @Test + public void shouldParseExcelFile() throws IOException { + ExcelMulti.ExcelMultiParser parser = new ExcelMulti.ExcelMultiParser("excelMulti"); + File file = new File("src/test/resources/test_multi.xlsx"); + ReportDto report = parser.parse(file); + assertThat(report.getItems()).hasSize(4); + assertThat(report.getItems().get(0).getName()).isEqualTo("1.0"); + assertThat(report.getItems().get(0).getResult().get("Value")).isEqualTo(10); + assertThat(report.getItems().get(1).getName()).isEqualTo("2.0"); + assertThat(report.getItems().get(1).getResult().get("Value")).isEqualTo(20); + assertThat(report.getItems().get(2).getName()).isEqualTo("3.0"); + assertThat(report.getItems().get(2).getResult().get("Value")).isEqualTo(30); + assertThat(report.getItems().get(3).getName()).isEqualTo("4.0"); + assertThat(report.getItems().get(3).getResult().get("Value")).isEqualTo(40); + } + + @Test(expected = IOException.class) + public void shouldThrowExceptionForInconsistentHeaders() throws IOException { + ExcelMulti.ExcelMultiParser parser = new ExcelMulti.ExcelMultiParser("excelMulti"); + File file = new File("src/test/resources/test_multi_inconsistent.xlsx"); + parser.parse(file); + } +} diff --git a/src/test/java/io/jenkins/plugins/reporter/provider/ExcelTest.java b/src/test/java/io/jenkins/plugins/reporter/provider/ExcelTest.java new file mode 100644 index 00000000..86b4e0d1 --- /dev/null +++ b/src/test/java/io/jenkins/plugins/reporter/provider/ExcelTest.java @@ -0,0 +1,27 @@ +package io.jenkins.plugins.reporter.provider; + +import io.jenkins.plugins.reporter.model.ReportDto; +import org.junit.Test; + +import java.io.File; +import java.io.IOException; + +import static org.assertj.core.api.Assertions.assertThat; + +public class ExcelTest { + + @Test + public void shouldParseExcelFile() throws IOException { + Excel.ExcelParser parser = new Excel.ExcelParser("excel"); + File file = new File("src/test/resources/test.xlsx"); + ReportDto report = parser.parse(file); + + System.out.println(report.getItems()); + + assertThat(report.getItems()).hasSize(2); + assertThat(report.getItems().get(0).getName()).isEqualTo("1.0"); + assertThat(report.getItems().get(0).getResult().get("Value")).isEqualTo(10); + assertThat(report.getItems().get(1).getName()).isEqualTo("2.0"); + assertThat(report.getItems().get(1).getResult().get("Value")).isEqualTo(20); + } +} diff --git a/src/test/resources/test.xlsx b/src/test/resources/test.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..d6d6b912c165ec247c56dbe1a0ccee6a03626d38 GIT binary patch literal 5211 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 literal 0 HcmV?d00001 diff --git a/src/test/resources/test_multi_inconsistent.xlsx b/src/test/resources/test_multi_inconsistent.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..44e7c5a20db4b95c0ced38e4f33e815b4f5e7212 GIT binary patch literal 5706 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 literal 0 HcmV?d00001