From 866b275c552744b37213503c50ce8cda000628d5 Mon Sep 17 00:00:00 2001 From: nchandra Date: Sat, 23 May 2026 06:34:43 +0100 Subject: [PATCH 1/2] Dedup multiple entries for package-root runner(not CherryPick @Scenarios) --- core/pom.xml | 2 +- .../zerocode/core/utils/SmartUtils.java | 11 +- .../zerocode/core/utils/SmartUtilsTest.java | 129 ++++++++++++++++++ http-testing-examples/pom.xml | 2 +- junit5-testing-examples/pom.xml | 2 +- kafka-testing-examples/pom.xml | 2 +- pom.xml | 2 +- zerocode-maven-archetype/pom.xml | 2 +- 8 files changed, 144 insertions(+), 8 deletions(-) diff --git a/core/pom.xml b/core/pom.xml index 7d5f3aac..68d0353b 100644 --- a/core/pom.xml +++ b/core/pom.xml @@ -6,7 +6,7 @@ org.jsmart zerocode-tdd-parent - 1.4.2 + 1.4.3 zerocode-tdd diff --git a/core/src/main/java/org/jsmart/zerocode/core/utils/SmartUtils.java b/core/src/main/java/org/jsmart/zerocode/core/utils/SmartUtils.java index b2221ce4..270dfb97 100644 --- a/core/src/main/java/org/jsmart/zerocode/core/utils/SmartUtils.java +++ b/core/src/main/java/org/jsmart/zerocode/core/utils/SmartUtils.java @@ -133,8 +133,15 @@ public static List getAllEndPointFiles(String packagePath) { ); } - endpointFiles.sort(null); - return endpointFiles; +// endpointFiles.sort(null); +// return endpointFiles; + + List deduplicatedFiles = endpointFiles.stream() + .distinct() + .sorted() + .collect(Collectors.toList()); + return deduplicatedFiles; + } private static List collectJsonFilesFromDirectory(URL resourceUrl, diff --git a/core/src/test/java/org/jsmart/zerocode/core/utils/SmartUtilsTest.java b/core/src/test/java/org/jsmart/zerocode/core/utils/SmartUtilsTest.java index fad2ee2a..450e6480 100644 --- a/core/src/test/java/org/jsmart/zerocode/core/utils/SmartUtilsTest.java +++ b/core/src/test/java/org/jsmart/zerocode/core/utils/SmartUtilsTest.java @@ -363,6 +363,135 @@ public void getAllEndPointFiles_skipsCorruptJar() throws Exception { } + @Test + public void getAllEndPointFiles_deduplicates_sameFilePath_fromMultipleClasspathEntries() throws Exception { + // Reproduce the bug: same package exists in two classpath roots (e.g. file: + jar:), + // causing the same relative file path to be collected twice before the distinct() fix. + String packagePath = "unit_test_files/engine_unit_test_jsons"; + + Path tempDir = Files.createTempDirectory("dedup-test"); + File jarFile = new File(tempDir.toFile(), "duplicate-scenarios.jar"); + + // Mirror the real test-resources files into a JAR so the same paths appear twice + File resourceDir = new File(getClass().getClassLoader().getResource(packagePath).getFile()); + try (java.util.jar.JarOutputStream jarOut = + new java.util.jar.JarOutputStream(Files.newOutputStream(jarFile.toPath()))) { + jarOut.putNextEntry(new java.util.jar.JarEntry(packagePath + "/")); + jarOut.closeEntry(); + for (File f : resourceDir.listFiles((d, n) -> n.endsWith(".json"))) { + jarOut.putNextEntry(new java.util.jar.JarEntry(packagePath + "/" + f.getName())); + jarOut.write(Files.readAllBytes(f.toPath())); + jarOut.closeEntry(); + } + } + + ClassLoader original = Thread.currentThread().getContextClassLoader(); + URLClassLoader urlClassLoader = new URLClassLoader(new URL[]{jarFile.toURI().toURL()}, original); + Thread.currentThread().setContextClassLoader(urlClassLoader); + + try { + List files = SmartUtils.getAllEndPointFiles(packagePath); + + // Must be 25, not 50 — distinct() collapses duplicate paths from two classpath roots + assertThat(files.size(), is(25)); + + // No duplicate entries + assertThat(new java.util.HashSet<>(files).size(), is(files.size())); + } finally { + Thread.currentThread().setContextClassLoader(original); + } + } + + @Test + public void getAllEndPointFiles_returnsSortedList() { + List files = SmartUtils.getAllEndPointFiles("unit_test_files/engine_unit_test_jsons"); + + for (int i = 0; i < files.size() - 1; i++) { + assertThat(files.get(i).compareTo(files.get(i + 1)) <= 0, is(true)); + } + } + + @Test + public void checkDuplicateScenarios_stillThrows_whenTwoDifferentFiles_shareSameName_afterFileDedup() throws Exception { + // Two distinct file paths each declare scenarioName "Given_When_Then_1". + // File-path dedup must NOT collapse them — they are different files. + // checkDuplicateScenarios must still detect the duplicate scenario name. + String packagePath = "unit_test_files/test_scenario_cases"; + + Path tempDir = Files.createTempDirectory("scenario-dedup-test"); + File jarFile = new File(tempDir.toFile(), "scenario-cases.jar"); + + File resourceDir = new File(getClass().getClassLoader().getResource(packagePath).getFile()); + try (java.util.jar.JarOutputStream jarOut = + new java.util.jar.JarOutputStream(Files.newOutputStream(jarFile.toPath()))) { + jarOut.putNextEntry(new java.util.jar.JarEntry(packagePath + "/")); + jarOut.closeEntry(); + for (File f : resourceDir.listFiles((d, n) -> n.endsWith(".json"))) { + jarOut.putNextEntry(new java.util.jar.JarEntry(packagePath + "/" + f.getName())); + jarOut.write(Files.readAllBytes(f.toPath())); + jarOut.closeEntry(); + } + } + + ClassLoader original = Thread.currentThread().getContextClassLoader(); + URLClassLoader urlClassLoader = new URLClassLoader(new URL[]{jarFile.toURI().toURL()}, original); + Thread.currentThread().setContextClassLoader(urlClassLoader); + + try { + // File-path dedup collapses the jar duplicates back to 3 distinct paths. + // The two files with the same scenarioName are still two distinct paths — must still throw. + expectedException.expect(RuntimeException.class); + expectedException.expectMessage("Oops! Duplicate scenario found, either rename or remove extra ones"); + smartUtils.checkDuplicateScenarios(packagePath); + } finally { + Thread.currentThread().setContextClassLoader(original); + } + } + + @Test + public void checkDuplicateScenarios_doesNotThrow_whenSameFileAppearsInTwoClasspathRoots() throws Exception { + // Before the distinct() fix, the same file appearing twice from two classpath roots caused + // a false-positive duplicate scenario name error. After the fix it must not throw. + String packagePath = "unit_test_files/no_dup_scenario_cases"; + + String scenario1 = "{\"scenarioName\":\"UniqueScenario_A\",\"steps\":[]}"; + String scenario2 = "{\"scenarioName\":\"UniqueScenario_B\",\"steps\":[]}"; + + // Build a real file: classpath root (temp dir with package structure) + Path fsRoot = Files.createTempDirectory("no-false-positive-fs"); + Path packageDir = fsRoot.resolve(packagePath); + Files.createDirectories(packageDir); + Files.write(packageDir.resolve("scenario_a.json"), scenario1.getBytes()); + Files.write(packageDir.resolve("scenario_b.json"), scenario2.getBytes()); + + // Build a JAR with the identical files — same relative paths will appear twice + File jarFile = new File(fsRoot.toFile(), "mirrored-scenarios.jar"); + try (java.util.jar.JarOutputStream jarOut = + new java.util.jar.JarOutputStream(Files.newOutputStream(jarFile.toPath()))) { + jarOut.putNextEntry(new java.util.jar.JarEntry(packagePath + "/")); + jarOut.closeEntry(); + jarOut.putNextEntry(new java.util.jar.JarEntry(packagePath + "/scenario_a.json")); + jarOut.write(scenario1.getBytes()); + jarOut.closeEntry(); + jarOut.putNextEntry(new java.util.jar.JarEntry(packagePath + "/scenario_b.json")); + jarOut.write(scenario2.getBytes()); + jarOut.closeEntry(); + } + + ClassLoader original = Thread.currentThread().getContextClassLoader(); + // Both classpath roots contain the same two files — paths appear twice before dedup + URLClassLoader urlClassLoader = new URLClassLoader( + new URL[]{fsRoot.toUri().toURL(), jarFile.toURI().toURL()}, original); + Thread.currentThread().setContextClassLoader(urlClassLoader); + + try { + // Should not throw — distinct() deduplicates the paths; no true duplicate scenario names + smartUtils.checkDuplicateScenarios(packagePath); + } finally { + Thread.currentThread().setContextClassLoader(original); + } + } + // Move this to File Util class private static File createCascadeIfNotExisting(String fileName) { try { diff --git a/http-testing-examples/pom.xml b/http-testing-examples/pom.xml index 99357867..287279cf 100644 --- a/http-testing-examples/pom.xml +++ b/http-testing-examples/pom.xml @@ -4,7 +4,7 @@ zerocode-tdd-parent org.jsmart - 1.4.2 + 1.4.3 org.jsmart diff --git a/junit5-testing-examples/pom.xml b/junit5-testing-examples/pom.xml index e2a8b30a..c587263b 100644 --- a/junit5-testing-examples/pom.xml +++ b/junit5-testing-examples/pom.xml @@ -4,7 +4,7 @@ zerocode-tdd-parent org.jsmart - 1.4.2 + 1.4.3 zerocode-tdd-jupiter diff --git a/kafka-testing-examples/pom.xml b/kafka-testing-examples/pom.xml index a36abf52..f90bd892 100644 --- a/kafka-testing-examples/pom.xml +++ b/kafka-testing-examples/pom.xml @@ -4,7 +4,7 @@ zerocode-tdd-parent org.jsmart - 1.4.2 + 1.4.3 kafka-testing-examples diff --git a/pom.xml b/pom.xml index ef90f742..d8d18c26 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ zerocode-tdd-parent org.jsmart - 1.4.2 + 1.4.3 pom ZeroCode TDD Parent diff --git a/zerocode-maven-archetype/pom.xml b/zerocode-maven-archetype/pom.xml index 356cf309..ff54f4d9 100644 --- a/zerocode-maven-archetype/pom.xml +++ b/zerocode-maven-archetype/pom.xml @@ -4,7 +4,7 @@ org.jsmart zerocode-tdd-parent - 1.4.2 + 1.4.3 zerocode-maven-archetype From 66832267dea84a51b483c4740a4a1f314c10677b Mon Sep 17 00:00:00 2001 From: nchandra Date: Tue, 26 May 2026 07:52:05 +0100 Subject: [PATCH 2/2] Text min report from CSV report objects(non-null fields only) --- .../constants/ZeroCodeReportConstants.java | 1 + .../engine/listener/TestUtilityListener.java | 1 + .../core/report/ZeroCodeReportGenerator.java | 2 + .../report/ZeroCodeReportGeneratorImpl.java | 142 +++++++++++++++++- .../ZeroCodeReportGeneratorImplTest.java | 99 ++++++++++++ 5 files changed, 237 insertions(+), 8 deletions(-) diff --git a/core/src/main/java/org/jsmart/zerocode/core/constants/ZeroCodeReportConstants.java b/core/src/main/java/org/jsmart/zerocode/core/constants/ZeroCodeReportConstants.java index 4442dd52..23d49f09 100644 --- a/core/src/main/java/org/jsmart/zerocode/core/constants/ZeroCodeReportConstants.java +++ b/core/src/main/java/org/jsmart/zerocode/core/constants/ZeroCodeReportConstants.java @@ -7,6 +7,7 @@ public interface ZeroCodeReportConstants { String TARGET_FULL_REPORT_DIR = "target/"; String TARGET_REPORT_DIR = "target/zerocode-test-reports/"; String TARGET_FULL_REPORT_CSV_FILE_NAME = "zerocode-junit-granular-report.csv"; + String TARGET_FULL_REPORT_TXT_FILE_NAME = "zerocode-junit-granular-report.txt"; String TARGET_FILE_NAME = "target/zerocode-junit-interactive-fuzzy-search.html"; String HIGH_CHART_HTML_FILE_NAME = "zerocode_results_chart"; String AUTHOR_MARKER_OLD = "@@"; //Deprecated diff --git a/core/src/main/java/org/jsmart/zerocode/core/engine/listener/TestUtilityListener.java b/core/src/main/java/org/jsmart/zerocode/core/engine/listener/TestUtilityListener.java index 96142ae7..49fe477f 100644 --- a/core/src/main/java/org/jsmart/zerocode/core/engine/listener/TestUtilityListener.java +++ b/core/src/main/java/org/jsmart/zerocode/core/engine/listener/TestUtilityListener.java @@ -58,6 +58,7 @@ public void runPostFinished() { private void generateChartsAndReports() { reportGenerator.generateCsvReport(); + reportGenerator.generateTableReport(); /** * Not compatible with open source license i.e. why not activated. But if it has to be used inside intranet, diff --git a/core/src/main/java/org/jsmart/zerocode/core/report/ZeroCodeReportGenerator.java b/core/src/main/java/org/jsmart/zerocode/core/report/ZeroCodeReportGenerator.java index 6e71fbfb..9210ce96 100644 --- a/core/src/main/java/org/jsmart/zerocode/core/report/ZeroCodeReportGenerator.java +++ b/core/src/main/java/org/jsmart/zerocode/core/report/ZeroCodeReportGenerator.java @@ -10,4 +10,6 @@ public interface ZeroCodeReportGenerator { void generateHighChartReport(); void generateExtentReport(); + + void generateTableReport(); } diff --git a/core/src/main/java/org/jsmart/zerocode/core/report/ZeroCodeReportGeneratorImpl.java b/core/src/main/java/org/jsmart/zerocode/core/report/ZeroCodeReportGeneratorImpl.java index 122796fa..32bf4817 100644 --- a/core/src/main/java/org/jsmart/zerocode/core/report/ZeroCodeReportGeneratorImpl.java +++ b/core/src/main/java/org/jsmart/zerocode/core/report/ZeroCodeReportGeneratorImpl.java @@ -24,6 +24,7 @@ import java.io.File; import java.io.IOException; +import java.io.PrintWriter; import java.time.LocalDateTime; import java.time.ZoneId; import java.util.*; @@ -119,9 +120,11 @@ public void generateExtentReport() { thisReport.getResults().forEach(thisScenario -> { ExtentTest test = extentReports.createTest(thisScenario.getScenarioName()); - /**This code checks if the scenario has meta data. - If it does, it iterates through each meta data entry and adds it to - the Extent report as an info label.**/ + /** + * This code checks if the scenario has meta data. + * If it does, it iterates through each meta data entry and adds it to + * the Extent report as an info label. + */ if (thisScenario.getMeta() != null) { for (Map.Entry> entry : thisScenario.getMeta().entrySet()) { String key = entry.getKey(); @@ -133,14 +136,14 @@ public void generateExtentReport() { // Assign Category test.assignCategory(DEFAULT_REGRESSION_CATEGORY); //Super set String[] hashTagsArray = optionalCategories(thisScenario.getScenarioName()).toArray(new String[0]); - if(hashTagsArray.length > 0) { + if (hashTagsArray.length > 0) { test.assignCategory(hashTagsArray); //Sub categories } // Assign Authors test.assignAuthor(DEFAULT_REGRESSION_AUTHOR); //Super set String[] authorsArray = optionalAuthors(thisScenario.getScenarioName()).toArray(new String[0]); - if(authorsArray.length > 0) { + if (authorsArray.length > 0) { test.assignAuthor(authorsArray); //Sub authors } @@ -207,12 +210,12 @@ protected List optionalCategories(String scenarioName) { private List deriveNames(String scenarioName, String marker) { List nameList = new ArrayList<>(); - for(String thisName : scenarioName.trim().split(" ")){ - if(thisName.startsWith(marker) && !thisName.startsWith(AUTHOR_MARKER_OLD)){ + for (String thisName : scenarioName.trim().split(" ")) { + if (thisName.startsWith(marker) && !thisName.startsWith(AUTHOR_MARKER_OLD)) { nameList.add(thisName); } // Depreciated, but still supports. Remove this via a new ticket - if(thisName.startsWith(AUTHOR_MARKER_OLD)){ + if (thisName.startsWith(AUTHOR_MARKER_OLD)) { nameList.add(thisName); } } @@ -448,6 +451,129 @@ protected void validateReportsFolderAndTheFilesExists(String reportsFolder) { } + @Override + public void generateTableReport() { + if (zeroCodeCsvFlattenedRows == null || zeroCodeCsvFlattenedRows.isEmpty()) { + LOGGER.warn("No CSV rows available — skipping table report generation."); + return; + } + + String table = buildTableReportContent(zeroCodeCsvFlattenedRows); + + // -------------------------------------------------------------------------------------- + // This is intentionally commented out here. + // Let the end-users control/print/log via runPostFinished() lifecycle method [OPTIONAL]. + // Also, at this point, end-users can download or view the .txt report file in "target/" + // -------------------------------------------------------------------------------------- + // LOGGER.info("\n{}", table); + + String txtFileName = resolveCsvReportName().replace(".csv", ".txt"); + File txtFile = new File(TARGET_FULL_REPORT_DIR + txtFileName); + try (PrintWriter pw = new PrintWriter(txtFile)) { + pw.print(table); + } catch (IOException e) { + LOGGER.error("Failed to write table report to {}: {}", txtFile.getPath(), e.getMessage()); + } + + LOGGER.info("Tabular .txt report written to: {}", txtFile.getPath()); + } + + String buildTableReportContent(List rows) { + final int FIELDS_COUNT = 5; + final int PADDING = 2; + final int SCEN_WIDTH = 48, + STEP_WIDTH = 25, + METH_WIDTH = 22, + RES_WIDTH = 8, + DELAY_WIDTH = 10; + + String colSepr = "+" + repeat('-', SCEN_WIDTH + PADDING) + + "+" + repeat('-', STEP_WIDTH + PADDING) + + "+" + repeat('-', METH_WIDTH + PADDING) + + "+" + repeat('-', RES_WIDTH + PADDING) + + "+" + repeat('-', DELAY_WIDTH + PADDING) + + "+"; + + int allFieldWidth = SCEN_WIDTH + STEP_WIDTH + METH_WIDTH + RES_WIDTH + DELAY_WIDTH; + int innerPlusCount = FIELDS_COUNT - 1; + int footRepeat = (FIELDS_COUNT * PADDING) + allFieldWidth + innerPlusCount; + + String footSep = "+" + repeat('-', footRepeat) + "+"; + + StringBuilder sb = new StringBuilder(); + sb.append(colSepr).append('\n'); + sb.append("| ").append(pad("SCENARIO", SCEN_WIDTH)).append(" | ") + .append(pad("STEP", STEP_WIDTH)).append(" | ") + .append(pad("METHOD", METH_WIDTH)).append(" | ") + .append(pad("RESULT ", RES_WIDTH)).append(" | ") + .append(pad("DELAY (ms)", DELAY_WIDTH)).append(" |\n"); + sb.append(colSepr).append('\n'); + + int passed = 0, failed = 0; + double min = Double.MAX_VALUE, max = Double.MIN_VALUE; + + for (ZeroCodeCsvReport row : rows) { + String scen = trunc(row.getScenarioName(), SCEN_WIDTH); + String step = pad(row.getStepName(), STEP_WIDTH); + String method = pad(row.getMethod(), METH_WIDTH); + boolean isPass = RESULT_PASS.equals(row.getResult()); + String resCell = isPass ? "PASSED ✅" : "FAILED ❌"; + double delay = row.getResponseDelayMilliSec() != null ? row.getResponseDelayMilliSec() : 0.0; + + if (isPass) passed++; + else failed++; + if (delay < min) min = delay; + if (delay > max) max = delay; + + sb.append("| ").append(scen).append(" | ") + .append(step).append(" | ") + .append(method).append(" | ") + .append(resCell).append("| ") + .append(rpad(delay, DELAY_WIDTH)).append(" |\n"); + } + + String summary = String.format( + "Total: %d | PASSED: %d | FAILED: %d | Min delay: %s ms | Max delay: %s ms", + rows.size(), passed, failed, fmt(min), fmt(max)); + + sb.append(footSep).append('\n'); + sb.append("| ").append(pad(summary, footRepeat - PADDING)).append(" |\n"); + sb.append(footSep).append('\n'); + + return sb.toString(); + } + + private static String repeat(char fillChar, int count) { + char[] arr = new char[count]; + Arrays.fill(arr, fillChar); + return new String(arr); + } + + private static String pad(String text, int width) { + if (text == null) text = ""; + if (text.length() >= width) return text.substring(0, width); + return text + repeat(' ', width - text.length()); + } + + private static String trunc(String text, int width) { + if (text == null) text = ""; + text = text.trim(); + if (text.length() <= width) return pad(text, width); + return text.substring(0, width - 2) + ".."; + } + + private static String rpad(double value, int width) { + String text = fmt(value); + if (text.length() >= width) return text; + return repeat(' ', width - text.length()) + text; + } + + private static String fmt(double value) { + return (value == Math.floor(value) && !Double.isInfinite(value)) + ? String.valueOf((long) value) + ".0" + : String.valueOf(value); + } + private static Date utilDateOf(LocalDateTime localDateTime) { return Date.from(localDateTime.atZone(ZoneId.systemDefault()).toInstant()); } diff --git a/core/src/test/java/org/jsmart/zerocode/core/report/ZeroCodeReportGeneratorImplTest.java b/core/src/test/java/org/jsmart/zerocode/core/report/ZeroCodeReportGeneratorImplTest.java index 8f8813ae..3a89f156 100644 --- a/core/src/test/java/org/jsmart/zerocode/core/report/ZeroCodeReportGeneratorImplTest.java +++ b/core/src/test/java/org/jsmart/zerocode/core/report/ZeroCodeReportGeneratorImplTest.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import org.jsmart.zerocode.core.di.provider.ObjectMapperProvider; import org.jsmart.zerocode.core.domain.reports.ZeroCodeReportStep; +import org.jsmart.zerocode.core.domain.reports.csv.ZeroCodeCsvReport; import org.junit.Before; import org.junit.Ignore; import org.junit.Rule; @@ -10,11 +11,13 @@ import org.junit.rules.ExpectedException; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Properties; import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.CoreMatchers.containsString; import static org.hamcrest.core.Is.is; import static org.jsmart.zerocode.core.constants.ZeroCodeReportConstants.RESULT_FAIL; import static org.jsmart.zerocode.core.constants.ZeroCodeReportConstants.RESULT_PASS; @@ -199,4 +202,100 @@ public void resolveCsvReportName_returnsDefault_whenZerocodePropertiesIsEmpty() assertThat(zeroCodeReportGenerator.resolveCsvReportName(), is(TARGET_FULL_REPORT_CSV_FILE_NAME)); } + // ------------------------------------------------------------------------- + // buildTableReportContent() unit tests + // ------------------------------------------------------------------------- + + private static ZeroCodeCsvReport csvRow(String scenario, String step, String method, + String result, double delay) { + return new ZeroCodeCsvReport(scenario, 0, step, 0, "corr-id", + result, method, "2026-01-01T00:00:00", "2026-01-01T00:00:01", delay); + } + + @Test + public void buildTableReport_allRowsHaveSameLineLength() { + List rows = Arrays.asList( + csvRow("Scenario One", "step_one", "GET", RESULT_PASS, 100.0), + csvRow("Scenario Two", "step_two", "POST", RESULT_FAIL, 200.0) + ); + + String table = zeroCodeReportGenerator.buildTableReportContent(rows); + String[] lines = table.split("\n"); + + // Every line must be the same display-string length + // (emoji rows are 1 string-char shorter but emoji is 2-display-wide — check string length) + int separatorLen = lines[0].length(); + for (String line : lines) { + assertThat("Line not same width as separator: [" + line + "]", + line.length() == separatorLen || line.length() == separatorLen - 1, is(true)); + } + } + + @Test + public void buildTableReport_truncatesLongScenarioAt48Chars() { + String longScenario = "GIVEN the very long scenario name that exceeds the column width limit set for the table"; + List rows = Arrays.asList( + csvRow(longScenario, "step", "GET", RESULT_PASS, 50.0) + ); + + String table = zeroCodeReportGenerator.buildTableReportContent(rows); + // truncated text ends with ".." and is exactly 48 chars (46 base + "..") + assertThat(table, containsString("GIVEN the very long scenario name that exceeds..")); + } + + @Test + public void buildTableReport_passedRowContainsCheckEmoji() { + List rows = Arrays.asList( + csvRow("My Scenario", "my_step", "GET", RESULT_PASS, 75.0) + ); + assertThat(zeroCodeReportGenerator.buildTableReportContent(rows), containsString("PASSED ✅")); + } + + @Test + public void buildTableReport_failedRowContainsCrossEmoji() { + List rows = Arrays.asList( + csvRow("My Scenario", "my_step", "POST", RESULT_FAIL, 0.0) + ); + assertThat(zeroCodeReportGenerator.buildTableReportContent(rows), containsString("FAILED ❌")); + } + + @Test + public void buildTableReport_footerContainsCorrectCounts() { + List rows = Arrays.asList( + csvRow("S1", "step1", "GET", RESULT_PASS, 10.0), + csvRow("S2", "step2", "POST", RESULT_PASS, 20.0), + csvRow("S3", "step3", "PUT", RESULT_FAIL, 5.0) + ); + + String table = zeroCodeReportGenerator.buildTableReportContent(rows); + assertThat(table, containsString("Total: 3")); + assertThat(table, containsString("PASSED: 2")); + assertThat(table, containsString("FAILED: 1")); + } + + @Test + public void buildTableReport_footerContainsMinMaxDelayWithPipeSeparator() { + List rows = Arrays.asList( + csvRow("S1", "step1", "GET", RESULT_PASS, 410.0), + csvRow("S2", "step2", "POST", RESULT_PASS, 1.0), + csvRow("S3", "step3", "DELETE", RESULT_FAIL, 0.0) + ); + + String table = zeroCodeReportGenerator.buildTableReportContent(rows); + assertThat(table, containsString("Min delay: 0.0 ms | Max delay: 410.0 ms")); + } + + @Test + public void buildTableReport_delayValuesAreRightAligned() { + List rows = Arrays.asList( + csvRow("S1", "step1", "GET", RESULT_PASS, 1000.0), + csvRow("S2", "step2", "GET", RESULT_PASS, 1.0) + ); + + String table = zeroCodeReportGenerator.buildTableReportContent(rows); + // Both delay values right-padded to same field width: " 1000.0" and " 1.0" + assertThat(table, containsString(" 1000.0 |")); + assertThat(table, containsString(" 1.0 |")); + } + } \ No newline at end of file