From 14f03eb495ddf35bda8b15718b96ea532715ded6 Mon Sep 17 00:00:00 2001 From: Edmund Miller Date: Wed, 2 Jul 2025 15:04:45 -0500 Subject: [PATCH 1/8] Add parameters support to AbstractTest and ITest interfaces - Introduced a Map for parameters in AbstractTest. - Added getter and setter methods for parameters in both AbstractTest and ITest. - Updated TestContext to initialize and manage test parameters dynamically. - Enhanced property access in TestContext to support test parameters. This change improves the flexibility of test configurations and parameter management. --- .../askimed/nf/test/core/AbstractTest.java | 16 +++++++ .../java/com/askimed/nf/test/core/ITest.java | 5 +++ .../com/askimed/nf/test/lang/TestContext.java | 42 ++++++++++++++++++- 3 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/askimed/nf/test/core/AbstractTest.java b/src/main/java/com/askimed/nf/test/core/AbstractTest.java index 39159a4e..b4d3db05 100644 --- a/src/main/java/com/askimed/nf/test/core/AbstractTest.java +++ b/src/main/java/com/askimed/nf/test/core/AbstractTest.java @@ -8,6 +8,11 @@ import com.askimed.nf.test.util.HashUtil; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.math.BigInteger; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HashMap; +import java.util.Map; import com.askimed.nf.test.config.Config; import com.askimed.nf.test.util.FileUtil; @@ -63,6 +68,9 @@ public abstract class AbstractTest implements ITest { private boolean updateSnapshot = false; private boolean ciMode = false; + private Map parameters = new HashMap(); + + public AbstractTest() { private boolean debug = false; @@ -261,4 +269,12 @@ public String toString() { return getHash().substring(0, 8) + ": " + getName(); } + public void setParameters(Map parameters) { + this.parameters = parameters; + } + + public Map getParameters() { + return parameters; + } + } diff --git a/src/main/java/com/askimed/nf/test/core/ITest.java b/src/main/java/com/askimed/nf/test/core/ITest.java index 0bbeab6e..1e8c624c 100644 --- a/src/main/java/com/askimed/nf/test/core/ITest.java +++ b/src/main/java/com/askimed/nf/test/core/ITest.java @@ -1,6 +1,7 @@ package com.askimed.nf.test.core; import java.io.File; +import java.util.Map; import com.askimed.nf.test.config.Config; @@ -38,4 +39,8 @@ public interface ITest extends ITaggable { public boolean isCIMode(); + public void setParameters(Map parameters); + + public Map getParameters(); + } diff --git a/src/main/java/com/askimed/nf/test/lang/TestContext.java b/src/main/java/com/askimed/nf/test/lang/TestContext.java index 1853f5a3..8416d141 100644 --- a/src/main/java/com/askimed/nf/test/lang/TestContext.java +++ b/src/main/java/com/askimed/nf/test/lang/TestContext.java @@ -1,6 +1,8 @@ package com.askimed.nf.test.lang; import java.io.IOException; +import java.util.HashMap; +import java.util.Map; import org.codehaus.groovy.control.CompilationFailedException; @@ -10,8 +12,9 @@ import com.askimed.nf.test.lang.workflow.Workflow; import groovy.lang.Closure; +import groovy.lang.GroovyObjectSupport; -public class TestContext { +public class TestContext extends GroovyObjectSupport { private ParamsMap params; @@ -35,9 +38,15 @@ public class TestContext { private WorkflowMeta workflow = new WorkflowMeta(); + private Map testParameters = new HashMap(); + public TestContext(ITest test) { params = new ParamsMap(this); this.test = test; + // Initialize test parameters from the test + if (test != null && test.getParameters() != null) { + this.testParameters = new HashMap(test.getParameters()); + } } public void init(AbstractTest test) { @@ -125,4 +134,35 @@ public void setWorkDir(String workDir) { throw new RuntimeException("Variable 'workDir' is read only"); } + public void setTestParameters(Map parameters) { + if (parameters != null) { + this.testParameters = new HashMap(parameters); + } + } + + public Map getTestParameters() { + return testParameters; + } + + @Override + public Object getProperty(String name) { + // First check if it's a test parameter + if (testParameters.containsKey(name)) { + return testParameters.get(name); + } + // Fall back to default Groovy property resolution + return super.getProperty(name); + } + + @Override + public void setProperty(String name, Object value) { + // Allow setting test parameters dynamically + if (testParameters.containsKey(name)) { + testParameters.put(name, value); + } else { + // Fall back to default Groovy property resolution + super.setProperty(name, value); + } + } + } From 28097408cb9b7dc47a7d10355dce676b27a1542d Mon Sep 17 00:00:00 2001 From: Edmund Miller Date: Wed, 2 Jul 2025 15:05:11 -0500 Subject: [PATCH 2/8] Add DataDrivenTest and DataTableParser classes for enhanced data-driven testing - Introduced DataDrivenTest class to facilitate setup, execution, and cleanup of data-driven tests using closures. - Added DataTableParser class to parse Spock-style where blocks, supporting data tables, data pipes, and variable assignments. - Enhanced FunctionTestSuite, PipelineTestSuite, ProcessTestSuite, and WorkflowTestSuite with testEach method for parameterized testing using data tables. - Implemented interpolation of test names based on provided templates and parameters. These changes improve the flexibility and usability of the testing framework for data-driven scenarios. --- .../askimed/nf/test/lang/DataDrivenTest.java | 81 +++++ .../askimed/nf/test/lang/DataTableParser.java | 299 ++++++++++++++++++ .../test/lang/function/FunctionTestSuite.java | 65 ++++ .../test/lang/pipeline/PipelineTestSuite.java | 65 ++++ .../nf/test/lang/process/ProcessTest.java | 30 ++ .../test/lang/process/ProcessTestSuite.java | 51 +++ .../test/lang/workflow/WorkflowTestSuite.java | 65 ++++ 7 files changed, 656 insertions(+) create mode 100644 src/main/java/com/askimed/nf/test/lang/DataDrivenTest.java create mode 100644 src/main/java/com/askimed/nf/test/lang/DataTableParser.java diff --git a/src/main/java/com/askimed/nf/test/lang/DataDrivenTest.java b/src/main/java/com/askimed/nf/test/lang/DataDrivenTest.java new file mode 100644 index 00000000..bf0db226 --- /dev/null +++ b/src/main/java/com/askimed/nf/test/lang/DataDrivenTest.java @@ -0,0 +1,81 @@ +package com.askimed.nf.test.lang; + +import com.askimed.nf.test.core.ITestSuite; +import com.askimed.nf.test.lang.DataTableParser.DataTable; + +import groovy.lang.Closure; + +public class DataDrivenTest { + + private ITestSuite testSuite; + private DataTable dataTable; + private Closure setupClosure; + private Closure whenClosure; + private Closure thenClosure; + private Closure cleanupClosure; + + public DataDrivenTest(ITestSuite testSuite) { + this.testSuite = testSuite; + } + + public void setup(Closure closure) { + this.setupClosure = closure; + } + + public void when(Closure closure) { + this.whenClosure = closure; + } + + public void then(Closure closure) { + this.thenClosure = closure; + } + + public void cleanup(Closure closure) { + this.cleanupClosure = closure; + } + + /** + * Parse the where block using Spock-style syntax supporting: + * 1. Data tables: a | b || c + * 2. Data pipes: a << [1,2,3] + * 3. Variable assignments: c = a + b + */ + public void where(String whereBlockText) { + this.dataTable = DataTableParser.parseWhereBlock(whereBlockText); + } + + /** + * Alternative where method that accepts a closure for Groovy DSL style + * This would capture the closure content and parse it + */ + public void where(Closure closure) { + // TODO: Implement closure-based where blocks + // This would require more sophisticated AST parsing of the closure + throw new UnsupportedOperationException("Closure-based where blocks not yet implemented. Please use string-based where blocks."); + } + + // Getters + public DataTable getDataTable() { + return dataTable; + } + + public Closure getSetupClosure() { + return setupClosure; + } + + public Closure getWhenClosure() { + return whenClosure; + } + + public Closure getThenClosure() { + return thenClosure; + } + + public Closure getCleanupClosure() { + return cleanupClosure; + } + + public ITestSuite getTestSuite() { + return testSuite; + } +} \ No newline at end of file diff --git a/src/main/java/com/askimed/nf/test/lang/DataTableParser.java b/src/main/java/com/askimed/nf/test/lang/DataTableParser.java new file mode 100644 index 00000000..7cdc71ab --- /dev/null +++ b/src/main/java/com/askimed/nf/test/lang/DataTableParser.java @@ -0,0 +1,299 @@ +package com.askimed.nf.test.lang; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class DataTableParser { + + private static final Pattern PIPE_SEPARATOR = Pattern.compile("\\s*\\|{1,2}\\s*"); + private static final Pattern DATA_PIPE = Pattern.compile("(\\w+)\\s*<<\\s*(.+)"); + private static final Pattern VARIABLE_ASSIGNMENT = Pattern.compile("(\\w+)\\s*=\\s*(.+)"); + + public static class DataTable { + private List parameterNames; + private List> rows; + + public DataTable(List parameterNames, List> rows) { + this.parameterNames = parameterNames; + this.rows = rows; + } + + public List getParameterNames() { + return parameterNames; + } + + public List> getRows() { + return rows; + } + + public int size() { + return rows.size(); + } + } + + /** + * Parse a Spock-style where block that can contain: + * 1. Data tables (a | b || c) + * 2. Data pipes (a << [1,2,3]) + * 3. Variable assignments (c = a + b) + */ + public static DataTable parseWhereBlock(String whereBlockText) { + if (whereBlockText == null || whereBlockText.trim().isEmpty()) { + throw new IllegalArgumentException("Where block text cannot be empty"); + } + + String[] lines = whereBlockText.trim().split("\\n"); + + // Check if this is a data table (contains | characters) + if (containsDataTable(lines)) { + return parseDataTable(lines); + } + + // Otherwise, parse as data pipes and variable assignments + return parseDataPipesAndAssignments(lines); + } + + private static boolean containsDataTable(String[] lines) { + for (String line : lines) { + if (line.trim().contains("|")) { + return true; + } + } + return false; + } + + /** + * Parse traditional data table format: + * name | number | expected + * "alice" | 1 | true + * "bob" | 2 | false + */ + private static DataTable parseDataTable(String[] lines) { + if (lines.length < 2) { + throw new IllegalArgumentException("Data table must have at least a header row and one data row"); + } + + // Parse header row to get parameter names + String headerLine = lines[0].trim(); + String[] headers = PIPE_SEPARATOR.split(headerLine); + List parameterNames = new ArrayList(); + for (String header : headers) { + String trimmed = header.trim(); + if (!trimmed.isEmpty()) { + parameterNames.add(trimmed); + } + } + + // Parse data rows + List> rows = new ArrayList>(); + for (int i = 1; i < lines.length; i++) { + String dataLine = lines[i].trim(); + if (dataLine.isEmpty()) { + continue; // Skip empty lines + } + + String[] values = PIPE_SEPARATOR.split(dataLine); + if (values.length != parameterNames.size()) { + throw new IllegalArgumentException("Data row " + i + " has " + values.length + + " values but header has " + parameterNames.size() + " parameters"); + } + + Map row = new HashMap(); + for (int j = 0; j < parameterNames.size(); j++) { + String rawValue = values[j].trim(); + Object parsedValue = parseValue(rawValue); + row.put(parameterNames.get(j), parsedValue); + } + rows.add(row); + } + + return new DataTable(parameterNames, rows); + } + + /** + * Parse data pipes and variable assignments: + * a << [1, 2, 3] + * b << ["x", "y", "z"] + * c = a + b.length() + */ + private static DataTable parseDataPipesAndAssignments(String[] lines) { + Map> dataPipes = new HashMap>(); + Map assignments = new HashMap(); + List parameterNames = new ArrayList(); + + // First pass: parse data pipes and assignments + for (String line : lines) { + line = line.trim(); + if (line.isEmpty()) continue; + + Matcher dataPipeMatcher = DATA_PIPE.matcher(line); + Matcher assignmentMatcher = VARIABLE_ASSIGNMENT.matcher(line); + + if (dataPipeMatcher.matches()) { + String varName = dataPipeMatcher.group(1).trim(); + String dataExpr = dataPipeMatcher.group(2).trim(); + List values = parseDataPipeExpression(dataExpr); + dataPipes.put(varName, values); + if (!parameterNames.contains(varName)) { + parameterNames.add(varName); + } + } else if (assignmentMatcher.matches()) { + String varName = assignmentMatcher.group(1).trim(); + String expression = assignmentMatcher.group(2).trim(); + assignments.put(varName, expression); + if (!parameterNames.contains(varName)) { + parameterNames.add(varName); + } + } + } + + // Determine number of iterations (max size of data pipes) + int maxIterations = 0; + for (List values : dataPipes.values()) { + maxIterations = Math.max(maxIterations, values.size()); + } + + if (maxIterations == 0 && assignments.isEmpty()) { + throw new IllegalArgumentException("No data pipes or assignments found in where block"); + } + + // Generate rows + List> rows = new ArrayList>(); + for (int i = 0; i < Math.max(maxIterations, 1); i++) { + Map row = new HashMap(); + + // Add data pipe values + for (Map.Entry> entry : dataPipes.entrySet()) { + String varName = entry.getKey(); + List values = entry.getValue(); + Object value = (i < values.size()) ? values.get(i) : values.get(values.size() - 1); + row.put(varName, value); + } + + // Evaluate assignments (simplified - in real implementation would need expression evaluator) + for (Map.Entry entry : assignments.entrySet()) { + String varName = entry.getKey(); + String expression = entry.getValue(); + Object value = evaluateSimpleExpression(expression, row); + row.put(varName, value); + } + + rows.add(row); + } + + return new DataTable(parameterNames, rows); + } + + /** + * Parse data pipe expressions like [1, 2, 3] or ["a", "b", "c"] + */ + private static List parseDataPipeExpression(String expression) { + List values = new ArrayList(); + + // Remove brackets if present + expression = expression.trim(); + if (expression.startsWith("[") && expression.endsWith("]")) { + expression = expression.substring(1, expression.length() - 1); + } + + // Split by comma and parse each value + String[] parts = expression.split(","); + for (String part : parts) { + values.add(parseValue(part.trim())); + } + + return values; + } + + /** + * Simple expression evaluator for basic arithmetic and string operations + */ + private static Object evaluateSimpleExpression(String expression, Map variables) { + // This is a very simplified evaluator + // In a real implementation, you'd want a proper expression parser + + // Replace variables in the expression + for (Map.Entry var : variables.entrySet()) { + String varName = var.getKey(); + Object varValue = var.getValue(); + expression = expression.replace(varName, String.valueOf(varValue)); + } + + // Try to evaluate as a simple value + return parseValue(expression); + } + + /** + * Parse individual values, handling strings, numbers, booleans, etc. + */ + private static Object parseValue(String value) { + if (value == null || value.trim().isEmpty()) { + return ""; + } + + value = value.trim(); + + // Handle quoted strings + if ((value.startsWith("\"") && value.endsWith("\"")) || + (value.startsWith("'") && value.endsWith("'"))) { + return value.substring(1, value.length() - 1); + } + + // Handle booleans + if ("true".equalsIgnoreCase(value)) { + return true; + } + if ("false".equalsIgnoreCase(value)) { + return false; + } + + // Handle null + if ("null".equalsIgnoreCase(value)) { + return null; + } + + // Try to parse as integer + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + // Not an integer, continue + } + + // Try to parse as double + try { + return Double.parseDouble(value); + } catch (NumberFormatException e) { + // Not a number, return as string + } + + // Return as string + return value; + } + + /** + * Parse variable assignments like "x = 1, y = 2" + */ + public static Map parseVariableAssignments(String assignments) { + Map variables = new HashMap(); + + if (assignments == null || assignments.trim().isEmpty()) { + return variables; + } + + String[] parts = assignments.split(","); + for (String part : parts) { + Matcher matcher = VARIABLE_ASSIGNMENT.matcher(part.trim()); + if (matcher.matches()) { + String varName = matcher.group(1).trim(); + String varValue = matcher.group(2).trim(); + variables.put(varName, parseValue(varValue)); + } + } + + return variables; + } +} \ No newline at end of file diff --git a/src/main/java/com/askimed/nf/test/lang/function/FunctionTestSuite.java b/src/main/java/com/askimed/nf/test/lang/function/FunctionTestSuite.java index 2e74b089..ecbdb74b 100644 --- a/src/main/java/com/askimed/nf/test/lang/function/FunctionTestSuite.java +++ b/src/main/java/com/askimed/nf/test/lang/function/FunctionTestSuite.java @@ -1,6 +1,10 @@ package com.askimed.nf.test.lang.function; import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import com.askimed.nf.test.core.AbstractTestSuite; import com.askimed.nf.test.core.ITest; @@ -35,4 +39,65 @@ protected ITest getNewTestInstance(String name) { return test; } + public void testEach(String nameTemplate, Map> dataTable, + @DelegatesTo(value = FunctionTest.class, strategy = Closure.DELEGATE_ONLY) final Closure closure) { + + // Extract parameter names and values + List paramNames = new ArrayList(dataTable.keySet()); + List> paramValues = new ArrayList>(); + for (String paramName : paramNames) { + paramValues.add(dataTable.get(paramName)); + } + + // Validate all parameter lists have same length + if (paramValues.isEmpty()) { + throw new IllegalArgumentException("Data table cannot be empty"); + } + + int testCount = paramValues.get(0).size(); + for (List values : paramValues) { + if (values.size() != testCount) { + throw new IllegalArgumentException("All parameter lists must have the same length"); + } + } + + // Generate individual tests for each data row + for (int i = 0; i < testCount; i++) { + Map testParams = new HashMap(); + for (int j = 0; j < paramNames.size(); j++) { + testParams.put(paramNames.get(j), paramValues.get(j).get(i)); + } + + // Create test name by replacing placeholders + String testName = interpolateTestName(nameTemplate, testParams, i); + + // Create parameterized test + final FunctionTest test = new FunctionTest(this); + test.name(testName); + test.setParameters(testParams); + + closure.setDelegate(test); + closure.setResolveStrategy(Closure.DELEGATE_ONLY); + closure.call(); + + addTest(test); + } + } + + private String interpolateTestName(String nameTemplate, Map params, int index) { + String result = nameTemplate; + + // Replace parameter placeholders like #paramName with actual values + for (Map.Entry entry : params.entrySet()) { + String placeholder = "#" + entry.getKey(); + String value = entry.getValue() != null ? entry.getValue().toString() : "null"; + result = result.replace(placeholder, value); + } + + // Replace #index with the current row index + result = result.replace("#index", String.valueOf(index)); + + return result; + } + } \ No newline at end of file diff --git a/src/main/java/com/askimed/nf/test/lang/pipeline/PipelineTestSuite.java b/src/main/java/com/askimed/nf/test/lang/pipeline/PipelineTestSuite.java index fee5db10..d3dfbd7f 100644 --- a/src/main/java/com/askimed/nf/test/lang/pipeline/PipelineTestSuite.java +++ b/src/main/java/com/askimed/nf/test/lang/pipeline/PipelineTestSuite.java @@ -1,6 +1,10 @@ package com.askimed.nf.test.lang.pipeline; import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import com.askimed.nf.test.core.AbstractTestSuite; import com.askimed.nf.test.core.ITest; @@ -24,4 +28,65 @@ protected ITest getNewTestInstance(String name) { return test; } + public void testEach(String nameTemplate, Map> dataTable, + @DelegatesTo(value = PipelineTest.class, strategy = Closure.DELEGATE_ONLY) final Closure closure) { + + // Extract parameter names and values + List paramNames = new ArrayList(dataTable.keySet()); + List> paramValues = new ArrayList>(); + for (String paramName : paramNames) { + paramValues.add(dataTable.get(paramName)); + } + + // Validate all parameter lists have same length + if (paramValues.isEmpty()) { + throw new IllegalArgumentException("Data table cannot be empty"); + } + + int testCount = paramValues.get(0).size(); + for (List values : paramValues) { + if (values.size() != testCount) { + throw new IllegalArgumentException("All parameter lists must have the same length"); + } + } + + // Generate individual tests for each data row + for (int i = 0; i < testCount; i++) { + Map testParams = new HashMap(); + for (int j = 0; j < paramNames.size(); j++) { + testParams.put(paramNames.get(j), paramValues.get(j).get(i)); + } + + // Create test name by replacing placeholders + String testName = interpolateTestName(nameTemplate, testParams, i); + + // Create parameterized test + final PipelineTest test = new PipelineTest(this); + test.name(testName); + test.setParameters(testParams); + + closure.setDelegate(test); + closure.setResolveStrategy(Closure.DELEGATE_ONLY); + closure.call(); + + addTest(test); + } + } + + private String interpolateTestName(String nameTemplate, Map params, int index) { + String result = nameTemplate; + + // Replace parameter placeholders like #paramName with actual values + for (Map.Entry entry : params.entrySet()) { + String placeholder = "#" + entry.getKey(); + String value = entry.getValue() != null ? entry.getValue().toString() : "null"; + result = result.replace(placeholder, value); + } + + // Replace #index with the current row index + result = result.replace("#index", String.valueOf(index)); + + return result; + } + } \ No newline at end of file diff --git a/src/main/java/com/askimed/nf/test/lang/process/ProcessTest.java b/src/main/java/com/askimed/nf/test/lang/process/ProcessTest.java index 20d4a72b..e3089053 100644 --- a/src/main/java/com/askimed/nf/test/lang/process/ProcessTest.java +++ b/src/main/java/com/askimed/nf/test/lang/process/ProcessTest.java @@ -218,4 +218,34 @@ protected void writeWorkflowMock(File file) throws IOException, CompilationFaile } + @Override + public void setWithTrace(boolean withTrace) { + this.withTrace = withTrace; + } + + // Setter methods for data-driven testing + public void setSetupClosure(Closure closure) { + if (closure != null) { + setup(closure); + } + } + + public void setWhenClosure(Closure closure) { + if (closure != null) { + when(closure); + } + } + + public void setThenClosure(Closure closure) { + if (closure != null) { + then(closure); + } + } + + public void setCleanupClosure(Closure closure) { + if (closure != null) { + cleanup(closure); + } + } + } \ No newline at end of file diff --git a/src/main/java/com/askimed/nf/test/lang/process/ProcessTestSuite.java b/src/main/java/com/askimed/nf/test/lang/process/ProcessTestSuite.java index 4524d63c..637592d1 100644 --- a/src/main/java/com/askimed/nf/test/lang/process/ProcessTestSuite.java +++ b/src/main/java/com/askimed/nf/test/lang/process/ProcessTestSuite.java @@ -1,9 +1,14 @@ package com.askimed.nf.test.lang.process; import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import com.askimed.nf.test.core.AbstractTestSuite; import com.askimed.nf.test.core.ITest; +import com.askimed.nf.test.lang.DataDrivenTest; import com.askimed.nf.test.lang.TestCode; import groovy.lang.Closure; @@ -47,4 +52,50 @@ protected ITest getNewTestInstance(String name) { return test; } + public void testEach(String nameTemplate, + @DelegatesTo(value = DataDrivenTest.class, strategy = Closure.DELEGATE_ONLY) final Closure closure) { + + final DataDrivenTest dataDrivenTest = new DataDrivenTest(this); + closure.setDelegate(dataDrivenTest); + closure.setResolveStrategy(Closure.DELEGATE_ONLY); + closure.call(); + + // Generate individual tests for each data row + for (int i = 0; i < dataDrivenTest.getDataTable().size(); i++) { + Map testParams = dataDrivenTest.getDataTable().getRows().get(i); + + // Create test name by replacing placeholders + String testName = interpolateTestName(nameTemplate, testParams, i); + + // Create parameterized test + final ProcessTest test = new ProcessTest(this); + test.name(testName); + test.setParameters(testParams); + + // Set the test implementation from the data-driven test + test.setWhenClosure(dataDrivenTest.getWhenClosure()); + test.setThenClosure(dataDrivenTest.getThenClosure()); + test.setSetupClosure(dataDrivenTest.getSetupClosure()); + test.setCleanupClosure(dataDrivenTest.getCleanupClosure()); + + addTest(test); + } + } + + private String interpolateTestName(String nameTemplate, Map params, int index) { + String result = nameTemplate; + + // Replace parameter placeholders like #paramName with actual values + for (Map.Entry entry : params.entrySet()) { + String placeholder = "#" + entry.getKey(); + String value = entry.getValue() != null ? entry.getValue().toString() : "null"; + result = result.replace(placeholder, value); + } + + // Replace #index with the current row index + result = result.replace("#index", String.valueOf(index)); + + return result; + } + } \ No newline at end of file diff --git a/src/main/java/com/askimed/nf/test/lang/workflow/WorkflowTestSuite.java b/src/main/java/com/askimed/nf/test/lang/workflow/WorkflowTestSuite.java index 57f898a7..f11610d9 100644 --- a/src/main/java/com/askimed/nf/test/lang/workflow/WorkflowTestSuite.java +++ b/src/main/java/com/askimed/nf/test/lang/workflow/WorkflowTestSuite.java @@ -1,6 +1,10 @@ package com.askimed.nf.test.lang.workflow; import java.io.IOException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import com.askimed.nf.test.core.AbstractTestSuite; import com.askimed.nf.test.core.ITest; @@ -48,4 +52,65 @@ protected ITest getNewTestInstance(String name) { return test; } + public void testEach(String nameTemplate, Map> dataTable, + @DelegatesTo(value = WorkflowTest.class, strategy = Closure.DELEGATE_ONLY) final Closure closure) { + + // Extract parameter names and values + List paramNames = new ArrayList(dataTable.keySet()); + List> paramValues = new ArrayList>(); + for (String paramName : paramNames) { + paramValues.add(dataTable.get(paramName)); + } + + // Validate all parameter lists have same length + if (paramValues.isEmpty()) { + throw new IllegalArgumentException("Data table cannot be empty"); + } + + int testCount = paramValues.get(0).size(); + for (List values : paramValues) { + if (values.size() != testCount) { + throw new IllegalArgumentException("All parameter lists must have the same length"); + } + } + + // Generate individual tests for each data row + for (int i = 0; i < testCount; i++) { + Map testParams = new HashMap(); + for (int j = 0; j < paramNames.size(); j++) { + testParams.put(paramNames.get(j), paramValues.get(j).get(i)); + } + + // Create test name by replacing placeholders + String testName = interpolateTestName(nameTemplate, testParams, i); + + // Create parameterized test + final WorkflowTest test = new WorkflowTest(this); + test.name(testName); + test.setParameters(testParams); + + closure.setDelegate(test); + closure.setResolveStrategy(Closure.DELEGATE_ONLY); + closure.call(); + + addTest(test); + } + } + + private String interpolateTestName(String nameTemplate, Map params, int index) { + String result = nameTemplate; + + // Replace parameter placeholders like #paramName with actual values + for (Map.Entry entry : params.entrySet()) { + String placeholder = "#" + entry.getKey(); + String value = entry.getValue() != null ? entry.getValue().toString() : "null"; + result = result.replace(placeholder, value); + } + + // Replace #index with the current row index + result = result.replace("#index", String.valueOf(index)); + + return result; + } + } \ No newline at end of file From e32b1a640eeb6cec0482bfd6ddf82f7e3d41b4e9 Mon Sep 17 00:00:00 2001 From: Edmund Miller Date: Wed, 2 Jul 2025 15:05:20 -0500 Subject: [PATCH 3/8] Add data-driven tests for SAY_HELLO process - Introduced a new test file for the SAY_HELLO process using Spock-style data-driven testing. - Implemented various test scenarios including data table testing, data pipes testing, and mixed data assignments. - Enhanced test coverage by including edge cases and assertions for process success and failure. These additions improve the robustness of the testing framework for the SAY_HELLO process. --- example/data-driven-example.nf.test | 115 ++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 example/data-driven-example.nf.test diff --git a/example/data-driven-example.nf.test b/example/data-driven-example.nf.test new file mode 100644 index 00000000..6fad8d70 --- /dev/null +++ b/example/data-driven-example.nf.test @@ -0,0 +1,115 @@ +nextflow_process { + + name "Test process SAY_HELLO with Spock-style data-driven testing" + script "example/say-hello.nf" + process "SAY_HELLO" + + // Spock-style data table testing + testEach("Test with name=#name and number=#number") { + when { + process { + """ + input[0] = "$name" + input[1] = Channel.of($number) + """ + } + } + + then { + assert process.success + assert process.out.my_tuples.size() == expectedSize + assert process.out.my_tuples[0][0] == number + assert process.out.my_tuples[0][1] == name + } + + where: + """ + name | number | expectedSize + "alice" | 1 | 1 + "bob" | 2 | 1 + "charlie" | 3 | 1 + """ + } + + // Spock-style data pipes testing + testEach("Data pipes test #name-#number") { + when { + process { + """ + input[0] = "$name" + input[1] = Channel.of($number) + """ + } + } + + then { + if (shouldFail) { + assert process.failed + } else { + assert process.success + assert process.out.my_tuples[0][1] == name + } + } + + where: + """ + name << ["", "valid-name", "special!@#", "normal"] + number << [1, 2, 3, 4] + shouldFail << [true, false, true, false] + """ + } + + // Mixed data pipes and assignments + testEach("Complex test #name with #result") { + when { + process { + """ + input[0] = "$name" + input[1] = Channel.of($number) + """ + } + } + + then { + assert process.success + assert process.out.my_tuples[0][0] == number + assert result.contains(name) + } + + where: + """ + name << ["test1", "test2", "test3"] + number << [10, 20, 30] + result = name + "-" + number + """ + } + + // Alternative: Data table with || separator (like Spock) + testEach("Edge cases #input || #expected") { + when { + process { + """ + input[0] = "$input" + input[1] = Channel.of(1) + """ + } + } + + then { + if (expected) { + assert process.success + } else { + assert process.failed + } + } + + where: + """ + input || expected + "valid" || true + "" || false + "special!@#" || false + "normal-name" || true + """ + } +} \ No newline at end of file From 49b79a2b0300a1f30f79e62ff931a9d2d2e5c1da Mon Sep 17 00:00:00 2001 From: Edmund Miller Date: Wed, 2 Jul 2025 15:43:19 -0500 Subject: [PATCH 4/8] Fix data table parser expression evaluation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Enhance evaluateSimpleExpression to properly handle string concatenation with + operator - Add evaluateStringConcatenation method for basic expression evaluation - Use word boundaries in variable replacement to avoid partial matches - Support mixed string and number concatenation in computed assignments Fixes issue where expressions like 'name + "-" + number' were not being evaluated correctly. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../askimed/nf/test/lang/DataTableParser.java | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/askimed/nf/test/lang/DataTableParser.java b/src/main/java/com/askimed/nf/test/lang/DataTableParser.java index 7cdc71ab..a1c4bf1c 100644 --- a/src/main/java/com/askimed/nf/test/lang/DataTableParser.java +++ b/src/main/java/com/askimed/nf/test/lang/DataTableParser.java @@ -214,18 +214,47 @@ private static List parseDataPipeExpression(String expression) { */ private static Object evaluateSimpleExpression(String expression, Map variables) { // This is a very simplified evaluator - // In a real implementation, you'd want a proper expression parser + // Handle basic string concatenation with + operator // Replace variables in the expression for (Map.Entry var : variables.entrySet()) { String varName = var.getKey(); Object varValue = var.getValue(); - expression = expression.replace(varName, String.valueOf(varValue)); + // Use word boundaries to avoid partial replacements + expression = expression.replaceAll("\\b" + varName + "\\b", + varValue instanceof String ? "\"" + varValue + "\"" : String.valueOf(varValue)); + } + + // Handle simple string concatenation: "a" + "b" + "c" + if (expression.contains(" + ")) { + return evaluateStringConcatenation(expression); } // Try to evaluate as a simple value return parseValue(expression); } + + /** + * Evaluate simple string concatenation expressions + */ + private static String evaluateStringConcatenation(String expression) { + String[] parts = expression.split("\\s*\\+\\s*"); + StringBuilder result = new StringBuilder(); + + for (String part : parts) { + part = part.trim(); + // Remove quotes if present + if ((part.startsWith("\"") && part.endsWith("\"")) || + (part.startsWith("'") && part.endsWith("'"))) { + result.append(part.substring(1, part.length() - 1)); + } else { + // It's a number or unquoted value + result.append(part); + } + } + + return result.toString(); + } /** * Parse individual values, handling strings, numbers, booleans, etc. From b4a2240949437e6b204d8050d034acb134e6b00d Mon Sep 17 00:00:00 2001 From: Edmund Miller Date: Wed, 2 Jul 2025 15:43:35 -0500 Subject: [PATCH 5/8] Fix parameter propagation in data-driven tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Override setParameters in ProcessTest to propagate parameters to TestContext - Ensure test parameters are available as variables in test closures - Fix MissingPropertyException when accessing parameter variables like $name This enables proper parameter access in data-driven test execution. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../com/askimed/nf/test/lang/process/ProcessTest.java | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/main/java/com/askimed/nf/test/lang/process/ProcessTest.java b/src/main/java/com/askimed/nf/test/lang/process/ProcessTest.java index e3089053..eb99b5f1 100644 --- a/src/main/java/com/askimed/nf/test/lang/process/ProcessTest.java +++ b/src/main/java/com/askimed/nf/test/lang/process/ProcessTest.java @@ -248,4 +248,13 @@ public void setCleanupClosure(Closure closure) { } } + @Override + public void setParameters(Map parameters) { + super.setParameters(parameters); + // Also update the TestContext with the parameters + if (context != null && parameters != null) { + context.setTestParameters(parameters); + } + } + } \ No newline at end of file From eb4406774f09919b7c87384d52c9c223190e08d6 Mon Sep 17 00:00:00 2001 From: Edmund Miller Date: Wed, 2 Jul 2025 15:43:49 -0500 Subject: [PATCH 6/8] Fix where block syntax in data-driven examples MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Change from 'where:' label syntax to 'where()' method call syntax - Use proper Groovy method call format for where blocks - Update all example test cases to use correct syntax This ensures compatibility with Groovy's DSL parsing requirements. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- example/data-driven-example.nf.test | 20 ++++++++------------ 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/example/data-driven-example.nf.test b/example/data-driven-example.nf.test index 6fad8d70..e264a735 100644 --- a/example/data-driven-example.nf.test +++ b/example/data-driven-example.nf.test @@ -22,13 +22,12 @@ nextflow_process { assert process.out.my_tuples[0][1] == name } - where: - """ + where(""" name | number | expectedSize "alice" | 1 | 1 "bob" | 2 | 1 "charlie" | 3 | 1 - """ + """) } // Spock-style data pipes testing @@ -51,12 +50,11 @@ nextflow_process { } } - where: - """ + where(""" name << ["", "valid-name", "special!@#", "normal"] number << [1, 2, 3, 4] shouldFail << [true, false, true, false] - """ + """) } // Mixed data pipes and assignments @@ -76,12 +74,11 @@ nextflow_process { assert result.contains(name) } - where: - """ + where(""" name << ["test1", "test2", "test3"] number << [10, 20, 30] result = name + "-" + number - """ + """) } // Alternative: Data table with || separator (like Spock) @@ -103,13 +100,12 @@ nextflow_process { } } - where: - """ + where(""" input || expected "valid" || true "" || false "special!@#" || false "normal-name" || true - """ + """) } } \ No newline at end of file From b726889aa3eee3c45c2b03800dbf2d8956c177b9 Mon Sep 17 00:00:00 2001 From: Edmund Miller Date: Wed, 2 Jul 2025 15:44:12 -0500 Subject: [PATCH 7/8] Add comprehensive unit tests for DataTableParser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Test data table parsing with pipe separators - Test data pipes parsing with << syntax - Test mixed data pipes and computed assignments - Validate parameter extraction and expression evaluation - Use Java 8 compatible string concatenation syntax 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../nf/test/lang/DataTableParserTest.java | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 src/test/java/com/askimed/nf/test/lang/DataTableParserTest.java diff --git a/src/test/java/com/askimed/nf/test/lang/DataTableParserTest.java b/src/test/java/com/askimed/nf/test/lang/DataTableParserTest.java new file mode 100644 index 00000000..fa77134d --- /dev/null +++ b/src/test/java/com/askimed/nf/test/lang/DataTableParserTest.java @@ -0,0 +1,70 @@ +package com.askimed.nf.test.lang; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import com.askimed.nf.test.lang.DataTableParser.DataTable; + +public class DataTableParserTest { + + @Test + public void testDataTableParsing() { + String dataTableText = + "name | number | expected\n" + + "\"alice\" | 1 | true\n" + + "\"bob\" | 2 | false\n" + + "\"charlie\" | 3 | true"; + + DataTable dataTable = DataTableParser.parseWhereBlock(dataTableText); + + assertEquals(3, dataTable.size()); + assertEquals(3, dataTable.getParameterNames().size()); + assertTrue(dataTable.getParameterNames().contains("name")); + assertTrue(dataTable.getParameterNames().contains("number")); + assertTrue(dataTable.getParameterNames().contains("expected")); + + Map firstRow = dataTable.getRows().get(0); + assertEquals("alice", firstRow.get("name")); + assertEquals(1, firstRow.get("number")); + assertEquals(true, firstRow.get("expected")); + } + + @Test + public void testDataPipesParsing() { + String dataPipesText = + "name << [\"alice\", \"bob\", \"charlie\"]\n" + + "number << [1, 2, 3]\n" + + "expected << [true, false, true]"; + + DataTable dataTable = DataTableParser.parseWhereBlock(dataPipesText); + + assertEquals(3, dataTable.size()); + assertEquals(3, dataTable.getParameterNames().size()); + + Map firstRow = dataTable.getRows().get(0); + assertEquals("alice", firstRow.get("name")); + assertEquals(1, firstRow.get("number")); + assertEquals(true, firstRow.get("expected")); + } + + @Test + public void testMixedDataPipesAndAssignments() { + String mixedText = + "name << [\"test1\", \"test2\"]\n" + + "number << [10, 20]\n" + + "result = name + \"-\" + number"; + + DataTable dataTable = DataTableParser.parseWhereBlock(mixedText); + + assertEquals(2, dataTable.size()); + assertEquals(3, dataTable.getParameterNames().size()); + + Map firstRow = dataTable.getRows().get(0); + assertEquals("test1", firstRow.get("name")); + assertEquals(10, firstRow.get("number")); + assertEquals("test1-10", firstRow.get("result")); + } +} \ No newline at end of file From b834ab2108725168383876a8975103c3efe099f7 Mon Sep 17 00:00:00 2001 From: Edmund Miller Date: Wed, 2 Jul 2025 15:50:13 -0500 Subject: [PATCH 8/8] Add working examples for all data-driven test syntaxes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - simple-data-driven.nf.test: Basic data pipes example - data-table-test.nf.test: Pipe-separated data table example - double-pipe-test.nf.test: Double pipe separator example - mixed-syntax-test.nf.test: Mixed data pipes and computed assignments All examples have been tested and verified to work correctly with the implementation. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- example/data-table-test.nf.test | 31 ++++++++++++++++++++++++++ example/double-pipe-test.nf.test | 35 ++++++++++++++++++++++++++++++ example/mixed-syntax-test.nf.test | 31 ++++++++++++++++++++++++++ example/simple-data-driven.nf.test | 27 +++++++++++++++++++++++ 4 files changed, 124 insertions(+) create mode 100644 example/data-table-test.nf.test create mode 100644 example/double-pipe-test.nf.test create mode 100644 example/mixed-syntax-test.nf.test create mode 100644 example/simple-data-driven.nf.test diff --git a/example/data-table-test.nf.test b/example/data-table-test.nf.test new file mode 100644 index 00000000..1aa718d8 --- /dev/null +++ b/example/data-table-test.nf.test @@ -0,0 +1,31 @@ +nextflow_process { + + name "Data table syntax test" + script "example/say-hello.nf" + process "SAY_HELLO" + + // Test the pipe-separated data table syntax + testEach("Table test with #name and #number") { + when { + process { + """ + input[0] = "$name" + input[1] = Channel.of($number) + """ + } + } + + then { + assert process.success + assert process.out.my_tuples[0][1] == name + assert process.out.my_tuples[0][0] == number + } + + where(""" + name | number + "alice" | 1 + "bob" | 2 + "charlie" | 3 + """) + } +} \ No newline at end of file diff --git a/example/double-pipe-test.nf.test b/example/double-pipe-test.nf.test new file mode 100644 index 00000000..a00fc7d7 --- /dev/null +++ b/example/double-pipe-test.nf.test @@ -0,0 +1,35 @@ +nextflow_process { + + name "Double pipe syntax test" + script "example/say-hello.nf" + process "SAY_HELLO" + + // Test the double pipe separator syntax (input || expected) + testEach("Test #name || should be #expected") { + when { + process { + """ + input[0] = "$name" + input[1] = Channel.of(1) + """ + } + } + + then { + if (expected) { + assert process.success + assert process.out.my_tuples[0][1] == name + } else { + // For this test, all should succeed, but this shows the pattern + assert process.success + } + } + + where(""" + name || expected + "alice" || true + "bob" || true + "charlie" || true + """) + } +} \ No newline at end of file diff --git a/example/mixed-syntax-test.nf.test b/example/mixed-syntax-test.nf.test new file mode 100644 index 00000000..fa914a0e --- /dev/null +++ b/example/mixed-syntax-test.nf.test @@ -0,0 +1,31 @@ +nextflow_process { + + name "Mixed syntax test" + script "example/say-hello.nf" + process "SAY_HELLO" + + // Test mixed data pipes and computed assignments + testEach("Test #name with result=#result") { + when { + process { + """ + input[0] = "$name" + input[1] = Channel.of($number) + """ + } + } + + then { + assert process.success + assert process.out.my_tuples[0][1] == name + assert process.out.my_tuples[0][0] == number + assert result == name + "-" + number + } + + where(""" + name << ["test1", "test2", "test3"] + number << [10, 20, 30] + result = name + "-" + number + """) + } +} \ No newline at end of file diff --git a/example/simple-data-driven.nf.test b/example/simple-data-driven.nf.test new file mode 100644 index 00000000..4ff61357 --- /dev/null +++ b/example/simple-data-driven.nf.test @@ -0,0 +1,27 @@ +nextflow_process { + + name "Simple data-driven test" + script "example/say-hello.nf" + process "SAY_HELLO" + + // Basic data table test + testEach("Simple test with #name") { + when { + process { + """ + input[0] = "$name" + input[1] = Channel.of(1) + """ + } + } + + then { + assert process.success + assert process.out.my_tuples[0][1] == name + } + + where(""" + name << ["alice", "bob"] + """) + } +} \ No newline at end of file