This is an automated email from the ASF dual-hosted git repository.

hansva pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git


The following commit(s) were added to refs/heads/main by this push:
     new b929b00687 Add option to select java version and tests, fixes #5172 
(#7014)
b929b00687 is described below

commit b929b006872f01b6eed90706c5a360ee14b1623c
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Fri Apr 17 14:39:54 2026 +0200

    Add option to select java version and tests, fixes #5172 (#7014)
---
 .../hop/pipeline/transforms/janino/Janino.java     |   9 +
 .../pipeline/transforms/janino/JaninoDialog.java   |  40 +-
 .../hop/pipeline/transforms/janino/JaninoMeta.java |  28 ++
 .../transforms/janino/function/FunctionLib.java    |  12 +-
 .../UserDefinedJavaClassDialog.java                |  36 ++
 .../UserDefinedJavaClassMeta.java                  |  43 +-
 .../janino/messages/messages_en_US.properties      |   1 +
 .../messages/messages_en_US.properties             |   1 +
 .../transforms/janino/JaninoMetaFunctionTest.java  | 115 +++++
 .../pipeline/transforms/janino/JaninoMetaTest.java | 243 +++++++++-
 .../hop/pipeline/transforms/janino/JaninoTest.java | 383 ++++++++++++++++
 .../transforms/javafilter/JavaFilterMetaTest.java  | 494 +++++++++++++++++++++
 .../transforms/javafilter/JavaFilterTest.java      | 257 +++++++++++
 .../userdefinedjavaclass/FieldHelperTest.java      | 160 +++++++
 .../TransformClassBaseStaticMethodsTest.java       | 173 ++++++++
 .../TransformDefinitionsTest.java                  | 212 +++++++++
 .../UserDefinedJavaClassCodeSnippetsTest.java      |  86 ++++
 .../UserDefinedJavaClassDefTest.java               | 153 +++++++
 .../UserDefinedJavaClassMetaTest.java              | 373 ++++++++++++++++
 .../UserDefinedJavaClassTest.java                  | 188 ++++++++
 .../transforms/util/JaninoCheckerUtilTest.java     | 124 ++++++
 .../janino/src/test/resources/java-filter.xml      |  22 +
 .../src/test/resources/user-defined-java-class.xml |   1 +
 23 files changed, 3145 insertions(+), 9 deletions(-)

diff --git 
a/plugins/transforms/janino/src/main/java/org/apache/hop/pipeline/transforms/janino/Janino.java
 
b/plugins/transforms/janino/src/main/java/org/apache/hop/pipeline/transforms/janino/Janino.java
index 00bff1e971..3136dceadc 100644
--- 
a/plugins/transforms/janino/src/main/java/org/apache/hop/pipeline/transforms/janino/Janino.java
+++ 
b/plugins/transforms/janino/src/main/java/org/apache/hop/pipeline/transforms/janino/Janino.java
@@ -173,6 +173,15 @@ public class Janino extends BaseTransform<JaninoMeta, 
JaninoData> {
             data.expressionEvaluators[m].setThrownExceptions(new Class<?>[] 
{Exception.class});
             data.expressionEvaluators[m].setParentClassLoader(loader);
             
data.expressionEvaluators[m].setDefaultImports(functionLib.getImportPackages());
+            int javaVersion = meta.getEffectiveJavaTargetVersion();
+            data.expressionEvaluators[m].setTargetVersion(javaVersion);
+            // Janino default: parse up to Java 11 when source is unset, emit 
Java 6 bytecode when
+            // target is unset.
+            // Keep that permissive parse level for the default target (6) so 
existing expressions
+            // keep working.
+            if (javaVersion > JaninoMeta.JAVA_TARGET_VERSION_MIN) {
+              data.expressionEvaluators[m].setSourceVersion(javaVersion);
+            }
 
             // Validate Formula
             JaninoCheckerUtil janinoCheckerUtil = new JaninoCheckerUtil();
diff --git 
a/plugins/transforms/janino/src/main/java/org/apache/hop/pipeline/transforms/janino/JaninoDialog.java
 
b/plugins/transforms/janino/src/main/java/org/apache/hop/pipeline/transforms/janino/JaninoDialog.java
index a340064cd4..7cae557251 100644
--- 
a/plugins/transforms/janino/src/main/java/org/apache/hop/pipeline/transforms/janino/JaninoDialog.java
+++ 
b/plugins/transforms/janino/src/main/java/org/apache/hop/pipeline/transforms/janino/JaninoDialog.java
@@ -39,6 +39,7 @@ import org.apache.hop.ui.core.widget.ColumnInfo;
 import org.apache.hop.ui.core.widget.TableView;
 import org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
 import org.eclipse.swt.SWT;
+import org.eclipse.swt.custom.CCombo;
 import org.eclipse.swt.events.SelectionAdapter;
 import org.eclipse.swt.events.SelectionEvent;
 import org.eclipse.swt.layout.FormAttachment;
@@ -50,8 +51,15 @@ import org.eclipse.swt.widgets.TableItem;
 public class JaninoDialog extends BaseTransformDialog {
   private static final Class<?> PKG = JaninoMeta.class;
 
+  private static final String[] JAVA_TARGET_VERSION_ITEMS =
+      new String[] {
+        "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", 
"18", "19", "20", "21"
+      };
+
   private TableView wFields;
 
+  private CCombo wJavaTargetVersion;
+
   private final JaninoMeta currentMeta;
   private final JaninoMeta originalMeta;
 
@@ -75,12 +83,31 @@ public class JaninoDialog extends BaseTransformDialog {
 
     changed = currentMeta.hasChanged();
 
+    Label wlJavaTargetVersion = new Label(shell, SWT.RIGHT);
+    wlJavaTargetVersion.setText(
+        BaseMessages.getString(PKG, "JaninoDialog.JavaTargetVersion.Label"));
+    PropsUi.setLook(wlJavaTargetVersion);
+    FormData fdlJavaTargetVersion = new FormData();
+    fdlJavaTargetVersion.left = new FormAttachment(0, 0);
+    fdlJavaTargetVersion.right = new FormAttachment(middle, -margin);
+    fdlJavaTargetVersion.top = new FormAttachment(wSpacer, margin);
+    wlJavaTargetVersion.setLayoutData(fdlJavaTargetVersion);
+
+    wJavaTargetVersion = new CCombo(shell, SWT.BORDER | SWT.READ_ONLY);
+    PropsUi.setLook(wJavaTargetVersion);
+    wJavaTargetVersion.setItems(JAVA_TARGET_VERSION_ITEMS);
+    FormData fdJavaTargetVersion = new FormData();
+    fdJavaTargetVersion.left = new FormAttachment(middle, 0);
+    fdJavaTargetVersion.right = new FormAttachment(100, 0);
+    fdJavaTargetVersion.top = new FormAttachment(wSpacer, margin);
+    wJavaTargetVersion.setLayoutData(fdJavaTargetVersion);
+
     Label wlFields = new Label(shell, SWT.NONE);
     wlFields.setText(BaseMessages.getString(PKG, "JaninoDialog.Fields.Label"));
     PropsUi.setLook(wlFields);
     FormData fdlFields = new FormData();
     fdlFields.left = new FormAttachment(0, 0);
-    fdlFields.top = new FormAttachment(wSpacer, margin);
+    fdlFields.top = new FormAttachment(wJavaTargetVersion, margin);
     wlFields.setLayoutData(fdlFields);
 
     final int nrFields = currentMeta.getFunctions().size();
@@ -214,6 +241,14 @@ public class JaninoDialog extends BaseTransformDialog {
 
   /** Copy information from the meta-data currentMeta to the dialog fields. */
   public void getData() {
+    int effectiveVersion = currentMeta.getEffectiveJavaTargetVersion();
+    int versionIndex = effectiveVersion - JaninoMeta.JAVA_TARGET_VERSION_MIN;
+    if (versionIndex >= 0 && versionIndex < JAVA_TARGET_VERSION_ITEMS.length) {
+      wJavaTargetVersion.select(versionIndex);
+    } else {
+      wJavaTargetVersion.select(0);
+    }
+
     if (currentMeta.getFunctions() != null) {
       for (int i = 0; i < currentMeta.getFunctions().size(); i++) {
         JaninoMetaFunction function = currentMeta.getFunctions().get(i);
@@ -265,6 +300,9 @@ public class JaninoDialog extends BaseTransformDialog {
 
     transformName = wTransformName.getText(); // return value
 
+    currentMeta.setJavaTargetVersion(
+        Const.toInt(wJavaTargetVersion.getText(), 
JaninoMeta.JAVA_TARGET_VERSION_DEFAULT));
+
     currentMeta.getFunctions().clear();
     for (TableItem item : wFields.getNonEmptyItems()) {
       JaninoMetaFunction function = new JaninoMetaFunction();
diff --git 
a/plugins/transforms/janino/src/main/java/org/apache/hop/pipeline/transforms/janino/JaninoMeta.java
 
b/plugins/transforms/janino/src/main/java/org/apache/hop/pipeline/transforms/janino/JaninoMeta.java
index 1cd6d46443..5127f2ac0f 100644
--- 
a/plugins/transforms/janino/src/main/java/org/apache/hop/pipeline/transforms/janino/JaninoMeta.java
+++ 
b/plugins/transforms/janino/src/main/java/org/apache/hop/pipeline/transforms/janino/JaninoMeta.java
@@ -49,6 +49,12 @@ import org.apache.hop.pipeline.transform.TransformMeta;
 public class JaninoMeta extends BaseTransformMeta<Janino, JaninoData> {
   private static final Class<?> PKG = JaninoMeta.class;
 
+  /** Default matches Janino bytecode default ({@code 
UnitCompiler#getDefaultTargetVersion()}). */
+  public static final int JAVA_TARGET_VERSION_DEFAULT = 6;
+
+  public static final int JAVA_TARGET_VERSION_MIN = 6;
+  public static final int JAVA_TARGET_VERSION_MAX = 21;
+
   /** The formula calculations to be performed */
   @HopMetadataProperty(
       key = "formula",
@@ -56,6 +62,15 @@ public class JaninoMeta extends BaseTransformMeta<Janino, 
JaninoData> {
       injectionGroupDescription = "Janino.Injection.FORMULA")
   private List<JaninoMetaFunction> functions;
 
+  /**
+   * Java language / class file level passed to Janino ({@link
+   * org.codehaus.janino.ExpressionEvaluator #setSourceVersion} and {@link
+   * org.codehaus.janino.ExpressionEvaluator#setTargetVersion}). When unset or 
invalid, {@link
+   * #JAVA_TARGET_VERSION_DEFAULT} is used.
+   */
+  @HopMetadataProperty(key = "java_target_version")
+  private int javaTargetVersion = JAVA_TARGET_VERSION_DEFAULT;
+
   public JaninoMeta() {
     super();
     this.functions = new ArrayList<>();
@@ -64,6 +79,19 @@ public class JaninoMeta extends BaseTransformMeta<Janino, 
JaninoData> {
   public JaninoMeta(JaninoMeta m) {
     this();
     m.functions.forEach(f -> this.functions.add(new JaninoMetaFunction(f)));
+    this.javaTargetVersion = m.javaTargetVersion;
+  }
+
+  /**
+   * Resolved Janino compiler source/target version (major Java version 
number), for backwards
+   * compatibility when pipelines omit {@link #javaTargetVersion} or contain 
invalid values.
+   */
+  public int getEffectiveJavaTargetVersion() {
+    if (javaTargetVersion < JAVA_TARGET_VERSION_MIN
+        || javaTargetVersion > JAVA_TARGET_VERSION_MAX) {
+      return JAVA_TARGET_VERSION_DEFAULT;
+    }
+    return javaTargetVersion;
   }
 
   @Override
diff --git 
a/plugins/transforms/janino/src/main/java/org/apache/hop/pipeline/transforms/janino/function/FunctionLib.java
 
b/plugins/transforms/janino/src/main/java/org/apache/hop/pipeline/transforms/janino/function/FunctionLib.java
index 1eb0f1761e..37e4072725 100644
--- 
a/plugins/transforms/janino/src/main/java/org/apache/hop/pipeline/transforms/janino/function/FunctionLib.java
+++ 
b/plugins/transforms/janino/src/main/java/org/apache/hop/pipeline/transforms/janino/function/FunctionLib.java
@@ -29,6 +29,7 @@ import java.util.Comparator;
 import java.util.List;
 import java.util.Set;
 import java.util.stream.Collectors;
+import java.util.stream.Stream;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.plugins.IPlugin;
 import org.apache.hop.core.plugins.PluginRegistry;
@@ -175,7 +176,16 @@ public class FunctionLib {
       throws IOException {
     return ClassPath.from(classLoader).getAllClasses().stream()
         .filter(clazz -> clazz.getPackageName().contains(packageName))
-        .map(ClassPath.ClassInfo::load)
+        .flatMap(
+            clazz -> {
+              try {
+                return Stream.of(clazz.load());
+              } catch (Exception | Error e) {
+                // Skip classes that cannot be loaded (e.g. bad path-based 
class names from
+                // test-classpath entries, missing dependencies, incompatible 
bytecode).
+                return Stream.empty();
+              }
+            })
         .collect(Collectors.toSet());
   }
 }
diff --git 
a/plugins/transforms/janino/src/main/java/org/apache/hop/pipeline/transforms/userdefinedjavaclass/UserDefinedJavaClassDialog.java
 
b/plugins/transforms/janino/src/main/java/org/apache/hop/pipeline/transforms/userdefinedjavaclass/UserDefinedJavaClassDialog.java
index 7324381566..2377d09de1 100644
--- 
a/plugins/transforms/janino/src/main/java/org/apache/hop/pipeline/transforms/userdefinedjavaclass/UserDefinedJavaClassDialog.java
+++ 
b/plugins/transforms/janino/src/main/java/org/apache/hop/pipeline/transforms/userdefinedjavaclass/UserDefinedJavaClassDialog.java
@@ -40,6 +40,7 @@ import org.apache.hop.pipeline.Pipeline;
 import org.apache.hop.pipeline.PipelineHopMeta;
 import org.apache.hop.pipeline.PipelineMeta;
 import org.apache.hop.pipeline.transform.TransformMeta;
+import org.apache.hop.pipeline.transforms.janino.JaninoMeta;
 import org.apache.hop.pipeline.transforms.rowgenerator.GeneratorField;
 import org.apache.hop.pipeline.transforms.rowgenerator.RowGeneratorMeta;
 import 
org.apache.hop.pipeline.transforms.userdefinedjavaclass.UserDefinedJavaClassCodeSnippets.Category;
@@ -66,6 +67,7 @@ import 
org.apache.hop.ui.pipeline.transform.BaseTransformDialog;
 import org.apache.hop.ui.util.EnvironmentUtils;
 import org.apache.hop.ui.util.SwtSvgImageUtil;
 import org.eclipse.swt.SWT;
+import org.eclipse.swt.custom.CCombo;
 import org.eclipse.swt.custom.CTabFolder;
 import org.eclipse.swt.custom.CTabFolder2Adapter;
 import org.eclipse.swt.custom.CTabFolderEvent;
@@ -108,10 +110,17 @@ public class UserDefinedJavaClassDialog extends 
BaseTransformDialog {
   public static final String CONST_SET_VALUE = "setValue()";
   public static final String CONST_SNIPPITS_CATEGORY = "Snippits Category";
 
+  private static final String[] JAVA_TARGET_VERSION_ITEMS =
+      new String[] {
+        "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", 
"18", "19", "20", "21"
+      };
+
   private ModifyListener lsMod;
 
   private TableView wFields;
 
+  private CCombo wJavaTargetVersion;
+
   private Label wlPosition;
 
   private Button wClearResultFields;
@@ -224,6 +233,28 @@ public class UserDefinedJavaClassDialog extends 
BaseTransformDialog {
 
     Control lastControl = wSpacer;
 
+    Label wlJavaTargetVersion = new Label(shell, SWT.RIGHT);
+    wlJavaTargetVersion.setText(
+        BaseMessages.getString(PKG, 
"UserDefinedJavaClassDialog.JavaTargetVersion.Label"));
+    PropsUi.setLook(wlJavaTargetVersion);
+    FormData fdlJavaTargetVersion = new FormData();
+    fdlJavaTargetVersion.left = new FormAttachment(0, 0);
+    fdlJavaTargetVersion.right = new FormAttachment(middle, -margin);
+    fdlJavaTargetVersion.top = new FormAttachment(lastControl, margin);
+    wlJavaTargetVersion.setLayoutData(fdlJavaTargetVersion);
+
+    wJavaTargetVersion = new CCombo(shell, SWT.BORDER | SWT.READ_ONLY);
+    PropsUi.setLook(wJavaTargetVersion);
+    wJavaTargetVersion.setItems(JAVA_TARGET_VERSION_ITEMS);
+    wJavaTargetVersion.addModifyListener(lsMod);
+    FormData fdJavaTargetVersion = new FormData();
+    fdJavaTargetVersion.left = new FormAttachment(middle, 0);
+    fdJavaTargetVersion.right = new FormAttachment(100, 0);
+    fdJavaTargetVersion.top = new FormAttachment(lastControl, margin);
+    wJavaTargetVersion.setLayoutData(fdJavaTargetVersion);
+
+    lastControl = wJavaTargetVersion;
+
     SashForm wSash = new SashForm(shell, SWT.VERTICAL);
 
     // Top sash form
@@ -1027,6 +1058,8 @@ public class UserDefinedJavaClassDialog extends 
BaseTransformDialog {
 
     wClearResultFields.setSelection(input.isClearingResultFields());
 
+    
wJavaTargetVersion.setText(Integer.toString(input.getEffectiveJavaTargetVersion()));
+
     wFields.setRowNums();
     wFields.optWidth(true);
 
@@ -1118,6 +1151,9 @@ public class UserDefinedJavaClassDialog extends 
BaseTransformDialog {
   }
 
   private void getInfo(UserDefinedJavaClassMeta meta) {
+    meta.setJavaTargetVersion(
+        Const.toInt(wJavaTargetVersion.getText(), 
JaninoMeta.JAVA_TARGET_VERSION_DEFAULT));
+
     int nrFields = wFields.nrNonEmpty();
     List<FieldInfo> newFields = new ArrayList<>(nrFields);
     for (int i = 0; i < nrFields; i++) {
diff --git 
a/plugins/transforms/janino/src/main/java/org/apache/hop/pipeline/transforms/userdefinedjavaclass/UserDefinedJavaClassMeta.java
 
b/plugins/transforms/janino/src/main/java/org/apache/hop/pipeline/transforms/userdefinedjavaclass/UserDefinedJavaClassMeta.java
index 9552e1b3f7..4e10d11a8b 100644
--- 
a/plugins/transforms/janino/src/main/java/org/apache/hop/pipeline/transforms/userdefinedjavaclass/UserDefinedJavaClassMeta.java
+++ 
b/plugins/transforms/janino/src/main/java/org/apache/hop/pipeline/transforms/userdefinedjavaclass/UserDefinedJavaClassMeta.java
@@ -26,6 +26,7 @@ import java.lang.reflect.Method;
 import java.util.ArrayList;
 import java.util.Comparator;
 import java.util.List;
+import lombok.AccessLevel;
 import lombok.Getter;
 import lombok.Setter;
 import org.apache.hop.core.CheckResult;
@@ -46,6 +47,7 @@ import org.apache.hop.pipeline.PipelineMeta;
 import org.apache.hop.pipeline.transform.BaseTransformMeta;
 import org.apache.hop.pipeline.transform.ITransformIOMeta;
 import org.apache.hop.pipeline.transform.TransformMeta;
+import org.apache.hop.pipeline.transforms.janino.JaninoMeta;
 import org.apache.hop.pipeline.transforms.util.JaninoCheckerUtil;
 import org.codehaus.commons.compiler.CompileException;
 import org.codehaus.janino.ClassBodyEvaluator;
@@ -181,6 +183,34 @@ public class UserDefinedJavaClassMeta
       injectionGroupDescription = "UserDefinedJavaClass.Injection.PARAMETERS")
   private List<UsageParameter> usageParameters;
 
+  /**
+   * Janino bytecode / language level for compiling embedded user classes 
(same semantics as {@link
+   * JaninoMeta#getEffectiveJavaTargetVersion()}).
+   */
+  @Getter
+  @Setter(AccessLevel.NONE)
+  @HopMetadataProperty(key = "java_target_version")
+  private int javaTargetVersion = JaninoMeta.JAVA_TARGET_VERSION_DEFAULT;
+
+  public void setJavaTargetVersion(int javaTargetVersion) {
+    if (this.javaTargetVersion != javaTargetVersion) {
+      this.javaTargetVersion = javaTargetVersion;
+      this.hasChanged = true;
+    }
+  }
+
+  /**
+   * Resolved Janino compiler source/target version for {@link 
ClassBodyEvaluator}, for backwards
+   * compatibility when pipelines omit {@link #javaTargetVersion} or contain 
invalid values.
+   */
+  public int getEffectiveJavaTargetVersion() {
+    if (javaTargetVersion < JaninoMeta.JAVA_TARGET_VERSION_MIN
+        || javaTargetVersion > JaninoMeta.JAVA_TARGET_VERSION_MAX) {
+      return JaninoMeta.JAVA_TARGET_VERSION_DEFAULT;
+    }
+    return javaTargetVersion;
+  }
+
   public UserDefinedJavaClassMeta() {
     super();
     hasChanged = true;
@@ -201,14 +231,15 @@ public class UserDefinedJavaClassMeta
     m.targetTransformDefinitions.forEach(
         d -> this.targetTransformDefinitions.add(new 
TargetTransformDefinition(d)));
     m.usageParameters.forEach(u -> this.usageParameters.add(new 
UsageParameter(u)));
+    this.javaTargetVersion = m.javaTargetVersion;
   }
 
   @VisibleForTesting
   Class<?> cookClass(UserDefinedJavaClassDef def, ClassLoader clsLoader)
       throws CompileException, IOException, HopTransformException {
 
-    String checksum = def.getChecksum();
-    Class<?> rtn = UserDefinedJavaClassMeta.CLASS_CACHE.getIfPresent(checksum);
+    String cacheKey = def.getChecksum() + ":" + 
getEffectiveJavaTargetVersion();
+    Class<?> rtn = UserDefinedJavaClassMeta.CLASS_CACHE.getIfPresent(cacheKey);
     if (rtn != null) {
       return rtn;
     }
@@ -247,9 +278,15 @@ public class UserDefinedJavaClassMeta
         "org.apache.hop.core.variables.*",
         "java.util.*");
 
+    int javaVersion = getEffectiveJavaTargetVersion();
+    cbe.setTargetVersion(javaVersion);
+    if (javaVersion > JaninoMeta.JAVA_TARGET_VERSION_MIN) {
+      cbe.setSourceVersion(javaVersion);
+    }
+
     cbe.cook(new Scanner(null, sr));
     rtn = cbe.getClazz();
-    UserDefinedJavaClassMeta.CLASS_CACHE.put(checksum, rtn);
+    UserDefinedJavaClassMeta.CLASS_CACHE.put(cacheKey, rtn);
     return rtn;
   }
 
diff --git 
a/plugins/transforms/janino/src/main/resources/org/apache/hop/pipeline/transforms/janino/messages/messages_en_US.properties
 
b/plugins/transforms/janino/src/main/resources/org/apache/hop/pipeline/transforms/janino/messages/messages_en_US.properties
index 571ff8de87..480f1c13ce 100644
--- 
a/plugins/transforms/janino/src/main/resources/org/apache/hop/pipeline/transforms/janino/messages/messages_en_US.properties
+++ 
b/plugins/transforms/janino/src/main/resources/org/apache/hop/pipeline/transforms/janino/messages/messages_en_US.properties
@@ -28,6 +28,7 @@ Janino.Injection.VALUE_PRECISION=Precision
 Janino.Injection.VALUE_TYPE=Type
 Janino.Name=User defined Java expression
 JaninoDialog.DialogTitle=User defined Java expression
+JaninoDialog.JavaTargetVersion.Label=Java target version:
 JaninoDialog.Fields.Label=Fields: 
 JaninoDialog.Janino.Column=Java expression
 JaninoDialog.Length.Column=Length
diff --git 
a/plugins/transforms/janino/src/main/resources/org/apache/hop/pipeline/transforms/userdefinedjavaclass/messages/messages_en_US.properties
 
b/plugins/transforms/janino/src/main/resources/org/apache/hop/pipeline/transforms/userdefinedjavaclass/messages/messages_en_US.properties
index 293503295a..e857301ccd 100644
--- 
a/plugins/transforms/janino/src/main/resources/org/apache/hop/pipeline/transforms/userdefinedjavaclass/messages/messages_en_US.properties
+++ 
b/plugins/transforms/janino/src/main/resources/org/apache/hop/pipeline/transforms/userdefinedjavaclass/messages/messages_en_US.properties
@@ -80,6 +80,7 @@ UserDefinedJavaClassDialog.GettingFields.Label=Getting 
fields...please wait
 UserDefinedJavaClassDialog.InfoFields.Label=Info fields
 UserDefinedJavaClassDialog.InfoTransforms.Label=Info transforms\:
 UserDefinedJavaClassDialog.InputFields.Label=Input fields
+UserDefinedJavaClassDialog.JavaTargetVersion.Label=Java target version\:
 UserDefinedJavaClassDialog.NoTransformClassSet=No class tab has been set as 
the transform class\! Should the first tab set as active Script?
 UserDefinedJavaClassDialog.OutputFields.Label=Output fields
 UserDefinedJavaClassDialog.Parameters.Label=Parameters\:
diff --git 
a/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/janino/JaninoMetaFunctionTest.java
 
b/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/janino/JaninoMetaFunctionTest.java
new file mode 100644
index 0000000000..9df82d231e
--- /dev/null
+++ 
b/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/janino/JaninoMetaFunctionTest.java
@@ -0,0 +1,115 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.hop.pipeline.transforms.janino;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.apache.hop.core.row.IValueMeta;
+import org.junit.jupiter.api.Test;
+
+class JaninoMetaFunctionTest {
+
+  private static JaninoMetaFunction build(String fieldName) {
+    JaninoMetaFunction f = new JaninoMetaFunction();
+    f.setFieldName(fieldName);
+    f.setFormula("1+1");
+    f.setValueType(IValueMeta.TYPE_INTEGER);
+    f.setValueLength(9);
+    f.setValuePrecision(0);
+    f.setReplaceField("rep");
+    return f;
+  }
+
+  // ------------------------------------------------------------------ equals
+
+  @Test
+  void equals_sameFieldName_returnsTrue() {
+    JaninoMetaFunction a = build("x");
+    JaninoMetaFunction b = build("x");
+    assertTrue(a.equals(b));
+  }
+
+  @Test
+  void equals_differentFieldName_returnsFalse() {
+    assertFalse(build("a").equals(build("b")));
+  }
+
+  @Test
+  void equals_null_returnsFalse() {
+    assertFalse(build("x").equals(null));
+  }
+
+  @Test
+  void equals_differentClass_returnsFalse() {
+    assertFalse(build("x").equals("x"));
+  }
+
+  @Test
+  void equals_reflexive() {
+    JaninoMetaFunction f = build("y");
+    assertTrue(f.equals(f));
+  }
+
+  // ------------------------------------------------------------------ 
hashCode
+
+  @Test
+  void hashCode_equalObjects_sameHashCode() {
+    assertEquals(build("x").hashCode(), build("x").hashCode());
+  }
+
+  // ------------------------------------------------------------------ clone 
/ copy constructor
+
+  @Test
+  void copyConstructor_copiesAllFields() {
+    JaninoMetaFunction original = build("myField");
+    JaninoMetaFunction copy = new JaninoMetaFunction(original);
+
+    assertNotSame(original, copy);
+    assertEquals("myField", copy.getFieldName());
+    assertEquals("1+1", copy.getFormula());
+    assertEquals(IValueMeta.TYPE_INTEGER, copy.getValueType());
+    assertEquals(9, copy.getValueLength());
+    assertEquals(0, copy.getValuePrecision());
+    assertEquals("rep", copy.getReplaceField());
+  }
+
+  @Test
+  void clone_returnsEqualButDistinctInstance() {
+    JaninoMetaFunction original = build("z");
+    JaninoMetaFunction cloned = (JaninoMetaFunction) original.clone();
+
+    assertNotNull(cloned);
+    assertNotSame(original, cloned);
+    assertEquals(original.getFieldName(), cloned.getFieldName());
+    assertEquals(original.getFormula(), cloned.getFormula());
+  }
+
+  // ------------------------------------------------------------------ 
default constructor
+
+  @Test
+  void defaultConstructor_fieldsAreNullOrZero() {
+    JaninoMetaFunction f = new JaninoMetaFunction();
+    assertFalse(f.equals(null)); // doesn't throw
+    assertEquals(0, f.getValueType());
+    assertEquals(0, f.getValueLength());
+    assertEquals(0, f.getValuePrecision());
+  }
+}
diff --git 
a/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/janino/JaninoMetaTest.java
 
b/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/janino/JaninoMetaTest.java
index 9eee4ed8ee..2f08e89149 100644
--- 
a/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/janino/JaninoMetaTest.java
+++ 
b/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/janino/JaninoMetaTest.java
@@ -18,13 +18,23 @@
 package org.apache.hop.pipeline.transforms.janino;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
 
 import java.nio.file.Files;
 import java.nio.file.Path;
 import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.List;
 import java.util.Objects;
+import org.apache.hop.core.ICheckResult;
+import org.apache.hop.core.exception.HopTransformException;
 import org.apache.hop.core.plugins.PluginRegistry;
+import org.apache.hop.core.row.IRowMeta;
 import org.apache.hop.core.row.IValueMeta;
+import org.apache.hop.core.row.RowMeta;
 import org.apache.hop.core.row.value.ValueMetaDate;
 import org.apache.hop.core.row.value.ValueMetaInteger;
 import org.apache.hop.core.row.value.ValueMetaNumber;
@@ -32,8 +42,10 @@ import org.apache.hop.core.row.value.ValueMetaPlugin;
 import org.apache.hop.core.row.value.ValueMetaPluginType;
 import org.apache.hop.core.row.value.ValueMetaString;
 import org.apache.hop.core.xml.XmlHandler;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
 import org.apache.hop.metadata.serializer.memory.MemoryMetadataProvider;
 import org.apache.hop.metadata.serializer.xml.XmlMetadataUtil;
+import org.apache.hop.pipeline.PipelineMeta;
 import org.apache.hop.pipeline.transform.TransformMeta;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
@@ -51,6 +63,8 @@ class JaninoMetaTest {
     }
   }
 
+  // ------------------------------------------------------------------ 
load/save
+
   @Test
   void testLoadSave() throws Exception {
     Path path = 
Paths.get(Objects.requireNonNull(getClass().getResource("/janino.xml")).toURI());
@@ -62,10 +76,9 @@ class JaninoMetaTest {
         meta,
         new MemoryMetadataProvider());
 
-    validate(meta);
+    validateFixture(meta);
 
     // Do a round trip:
-    //
     String xmlCopy =
         XmlHandler.openTag(TransformMeta.XML_TAG)
             + XmlMetadataUtil.serializeObjectToXml(meta)
@@ -76,10 +89,11 @@ class JaninoMetaTest {
         JaninoMeta.class,
         metaCopy,
         new MemoryMetadataProvider());
-    validate(metaCopy);
+    validateFixture(metaCopy);
   }
 
-  private static void validate(JaninoMeta meta) {
+  private static void validateFixture(JaninoMeta meta) {
+    assertEquals(JaninoMeta.JAVA_TARGET_VERSION_DEFAULT, 
meta.getEffectiveJavaTargetVersion());
     assertEquals(3, meta.getFunctions().size());
     JaninoMetaFunction f = meta.getFunctions().getFirst();
     assertEquals("f1", f.getFieldName());
@@ -105,4 +119,225 @@ class JaninoMetaTest {
     assertEquals(2, f.getValuePrecision());
     assertEquals("replace3", f.getReplaceField());
   }
+
+  // ------------------------------------------------------------------
+  // getEffectiveJavaTargetVersion
+
+  @Test
+  void effectiveVersion_default_returnsDefault() {
+    JaninoMeta meta = new JaninoMeta();
+    assertEquals(JaninoMeta.JAVA_TARGET_VERSION_DEFAULT, 
meta.getEffectiveJavaTargetVersion());
+  }
+
+  @Test
+  void effectiveVersion_valid_returnsAsIs() {
+    JaninoMeta meta = new JaninoMeta();
+    meta.setJavaTargetVersion(11);
+    assertEquals(11, meta.getEffectiveJavaTargetVersion());
+    meta.setJavaTargetVersion(JaninoMeta.JAVA_TARGET_VERSION_MIN);
+    assertEquals(JaninoMeta.JAVA_TARGET_VERSION_MIN, 
meta.getEffectiveJavaTargetVersion());
+    meta.setJavaTargetVersion(JaninoMeta.JAVA_TARGET_VERSION_MAX);
+    assertEquals(JaninoMeta.JAVA_TARGET_VERSION_MAX, 
meta.getEffectiveJavaTargetVersion());
+  }
+
+  @Test
+  void effectiveVersion_belowMin_returnsDefault() {
+    JaninoMeta meta = new JaninoMeta();
+    meta.setJavaTargetVersion(0);
+    assertEquals(JaninoMeta.JAVA_TARGET_VERSION_DEFAULT, 
meta.getEffectiveJavaTargetVersion());
+    meta.setJavaTargetVersion(-1);
+    assertEquals(JaninoMeta.JAVA_TARGET_VERSION_DEFAULT, 
meta.getEffectiveJavaTargetVersion());
+  }
+
+  @Test
+  void effectiveVersion_aboveMax_returnsDefault() {
+    JaninoMeta meta = new JaninoMeta();
+    meta.setJavaTargetVersion(99);
+    assertEquals(JaninoMeta.JAVA_TARGET_VERSION_DEFAULT, 
meta.getEffectiveJavaTargetVersion());
+  }
+
+  // ------------------------------------------------------------------ clone
+
+  @Test
+  void clone_copiesVersion() {
+    JaninoMeta meta = new JaninoMeta();
+    meta.setJavaTargetVersion(17);
+    JaninoMetaFunction fn = new JaninoMetaFunction();
+    fn.setFieldName("x");
+    fn.setFormula("1");
+    fn.setValueType(IValueMeta.TYPE_INTEGER);
+    meta.getFunctions().add(fn);
+
+    JaninoMeta copy = (JaninoMeta) meta.clone();
+
+    assertEquals(17, copy.getJavaTargetVersion());
+    assertEquals(1, copy.getFunctions().size());
+    assertNotSame(meta.getFunctions().get(0), copy.getFunctions().get(0));
+  }
+
+  // ------------------------------------------------------------------ 
getFields
+
+  @Test
+  void getFields_newField_addsToRow() throws HopTransformException {
+    JaninoMeta meta = new JaninoMeta();
+    JaninoMetaFunction fn = new JaninoMetaFunction();
+    fn.setFieldName("added");
+    fn.setFormula("1+1");
+    fn.setValueType(IValueMeta.TYPE_INTEGER);
+    fn.setValueLength(9);
+    fn.setValuePrecision(0);
+    meta.getFunctions().add(fn);
+
+    RowMeta row = new RowMeta();
+    row.addValueMeta(new ValueMetaString("existing"));
+
+    meta.getFields(row, "step", null, null, null, 
mock(IHopMetadataProvider.class));
+
+    assertEquals(2, row.size());
+    assertEquals("added", row.getValueMeta(1).getName());
+    assertEquals(IValueMeta.TYPE_INTEGER, row.getValueMeta(1).getType());
+  }
+
+  @Test
+  void getFields_replaceField_changesExistingEntry() throws 
HopTransformException {
+    JaninoMeta meta = new JaninoMeta();
+    JaninoMetaFunction fn = new JaninoMetaFunction();
+    fn.setFieldName("n");
+    fn.setFormula("n*2");
+    fn.setValueType(IValueMeta.TYPE_INTEGER);
+    fn.setValueLength(9);
+    fn.setValuePrecision(0);
+    fn.setReplaceField("n");
+    meta.getFunctions().add(fn);
+
+    RowMeta row = new RowMeta();
+    row.addValueMeta(new ValueMetaInteger("n"));
+
+    meta.getFields(row, "step", null, null, null, 
mock(IHopMetadataProvider.class));
+
+    assertEquals(1, row.size()); // replaced, not appended
+  }
+
+  @Test
+  void getFields_replaceFieldMissing_throwsHopTransformException() {
+    JaninoMeta meta = new JaninoMeta();
+    JaninoMetaFunction fn = new JaninoMetaFunction();
+    fn.setFieldName("x");
+    fn.setFormula("1");
+    fn.setValueType(IValueMeta.TYPE_INTEGER);
+    fn.setReplaceField("nonexistent");
+    meta.getFunctions().add(fn);
+
+    RowMeta row = new RowMeta();
+    row.addValueMeta(new ValueMetaString("other"));
+
+    assertThrows(
+        HopTransformException.class,
+        () -> meta.getFields(row, "step", null, null, null, 
mock(IHopMetadataProvider.class)));
+  }
+
+  @Test
+  void getFields_emptyFieldName_skipped() throws HopTransformException {
+    JaninoMeta meta = new JaninoMeta();
+    JaninoMetaFunction fn = new JaninoMetaFunction();
+    fn.setFieldName("");
+    fn.setFormula("1");
+    fn.setValueType(IValueMeta.TYPE_INTEGER);
+    meta.getFunctions().add(fn);
+
+    RowMeta row = new RowMeta();
+    row.addValueMeta(new ValueMetaString("existing"));
+
+    meta.getFields(row, "step", null, null, null, 
mock(IHopMetadataProvider.class));
+    assertEquals(1, row.size()); // nothing added
+  }
+
+  // ------------------------------------------------------------------ check
+
+  @Test
+  void check_noPrevFields_addsWarning() {
+    JaninoMeta meta = new JaninoMeta();
+    List<ICheckResult> remarks = new ArrayList<>();
+    meta.check(
+        remarks,
+        mock(PipelineMeta.class),
+        mock(TransformMeta.class),
+        null,
+        new String[] {"in"},
+        new String[0],
+        mock(IRowMeta.class),
+        null,
+        mock(IHopMetadataProvider.class));
+
+    assertTrue(remarks.stream().anyMatch(r -> r.getType() == 
ICheckResult.TYPE_RESULT_WARNING));
+  }
+
+  @Test
+  void check_withPrevFields_addsOk() {
+    JaninoMeta meta = new JaninoMeta();
+    RowMeta prev = new RowMeta();
+    prev.addValueMeta(new ValueMetaString("field1"));
+
+    List<ICheckResult> remarks = new ArrayList<>();
+    meta.check(
+        remarks,
+        mock(PipelineMeta.class),
+        mock(TransformMeta.class),
+        prev,
+        new String[] {"in"},
+        new String[0],
+        mock(IRowMeta.class),
+        null,
+        mock(IHopMetadataProvider.class));
+
+    assertTrue(remarks.stream().anyMatch(r -> r.getType() == 
ICheckResult.TYPE_RESULT_OK));
+  }
+
+  @Test
+  void check_noInputTransforms_addsError() {
+    JaninoMeta meta = new JaninoMeta();
+    List<ICheckResult> remarks = new ArrayList<>();
+    meta.check(
+        remarks,
+        mock(PipelineMeta.class),
+        mock(TransformMeta.class),
+        new RowMeta(),
+        new String[0],
+        new String[0],
+        mock(IRowMeta.class),
+        null,
+        mock(IHopMetadataProvider.class));
+
+    assertTrue(remarks.stream().anyMatch(r -> r.getType() == 
ICheckResult.TYPE_RESULT_ERROR));
+  }
+
+  @Test
+  void check_withInputTransforms_addsOk() {
+    JaninoMeta meta = new JaninoMeta();
+    RowMeta prev = new RowMeta();
+    prev.addValueMeta(new ValueMetaString("f"));
+
+    List<ICheckResult> remarks = new ArrayList<>();
+    meta.check(
+        remarks,
+        mock(PipelineMeta.class),
+        mock(TransformMeta.class),
+        prev,
+        new String[] {"in"},
+        new String[0],
+        mock(IRowMeta.class),
+        null,
+        mock(IHopMetadataProvider.class));
+
+    assertEquals(2, remarks.size());
+    assertEquals(ICheckResult.TYPE_RESULT_OK, remarks.get(0).getType());
+    assertEquals(ICheckResult.TYPE_RESULT_OK, remarks.get(1).getType());
+  }
+
+  // ------------------------------------------------------------------ 
supportsErrorHandling
+
+  @Test
+  void supportsErrorHandling_returnsTrue() {
+    assertTrue(new JaninoMeta().supportsErrorHandling());
+  }
 }
diff --git 
a/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/janino/JaninoTest.java
 
b/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/janino/JaninoTest.java
new file mode 100644
index 0000000000..cf4f00eef7
--- /dev/null
+++ 
b/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/janino/JaninoTest.java
@@ -0,0 +1,383 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.hop.pipeline.transforms.janino;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.Collections;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import org.apache.hop.core.IRowSet;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.logging.HopLogStore;
+import org.apache.hop.core.logging.ILoggingObject;
+import org.apache.hop.core.plugins.Plugin;
+import org.apache.hop.core.plugins.PluginRegistry;
+import org.apache.hop.core.plugins.TransformPluginType;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.row.IValueMeta;
+import org.apache.hop.core.row.RowMeta;
+import org.apache.hop.core.row.value.ValueMetaDate;
+import org.apache.hop.core.row.value.ValueMetaInteger;
+import org.apache.hop.core.row.value.ValueMetaNumber;
+import org.apache.hop.core.row.value.ValueMetaPlugin;
+import org.apache.hop.core.row.value.ValueMetaPluginType;
+import org.apache.hop.core.row.value.ValueMetaString;
+import org.apache.hop.pipeline.transform.TransformErrorMeta;
+import org.apache.hop.pipeline.transforms.mock.TransformMockHelper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mockito;
+
+/**
+ * Unit tests for {@link Janino#processRow()} covering the data-transform 
path, the {@link
+ * JaninoMeta#javaTargetVersion} compilation gate, and error-handling routing.
+ */
+class JaninoTest {
+
+  private TransformMockHelper<JaninoMeta, JaninoData> helper;
+
+  @BeforeAll
+  static void initPlugins() throws Exception {
+    HopLogStore.init();
+    // Register value meta types needed by the row meta machinery.
+    PluginRegistry registry = PluginRegistry.getInstance();
+    String[] valueMetaClasses = {
+      ValueMetaString.class.getName(),
+      ValueMetaInteger.class.getName(),
+      ValueMetaDate.class.getName(),
+      ValueMetaNumber.class.getName()
+    };
+    for (String cls : valueMetaClasses) {
+      registry.registerPluginClass(cls, ValueMetaPluginType.class, 
ValueMetaPlugin.class);
+    }
+    // Register the Janino transform as a native plugin so getClassLoader() 
returns a usable
+    // loader without triggering the full classpath scan that 
HopEnvironment.init() does.
+    Plugin janinoPlugin =
+        new Plugin(
+            new String[] {"Janino"},
+            TransformPluginType.class,
+            Janino.class,
+            "Scripting",
+            "User Defined Java Expression",
+            "",
+            "janino.svg",
+            false,
+            true, // native plugin → getClassLoader() returns registry's own 
loader
+            Map.of(Janino.class, Janino.class.getName()),
+            Collections.emptyList(),
+            null,
+            new String[0],
+            null,
+            false);
+    registry.registerPlugin(TransformPluginType.class, janinoPlugin);
+  }
+
+  @BeforeEach
+  void setUp() {
+    helper = new TransformMockHelper<>("Janino TEST", JaninoMeta.class, 
JaninoData.class);
+    when(helper.logChannelFactory.create(any(), any(ILoggingObject.class)))
+        .thenReturn(helper.iLogChannel);
+    when(helper.pipeline.isRunning()).thenReturn(true);
+  }
+
+  @AfterEach
+  void tearDown() {
+    helper.cleanUp();
+  }
+
+  // ------------------------------------------------------------------ helpers
+
+  private Janino buildSpy(JaninoMeta meta) {
+    return Mockito.spy(
+        new Janino(
+            helper.transformMeta, meta, new JaninoData(), 0, 
helper.pipelineMeta, helper.pipeline));
+  }
+
+  private static JaninoMetaFunction intFn(String name, String formula) {
+    JaninoMetaFunction fn = new JaninoMetaFunction();
+    fn.setFieldName(name);
+    fn.setFormula(formula);
+    fn.setValueType(IValueMeta.TYPE_INTEGER);
+    fn.setValueLength(-1);
+    fn.setValuePrecision(-1);
+    return fn;
+  }
+
+  private static JaninoMetaFunction strFn(String name, String formula) {
+    JaninoMetaFunction fn = new JaninoMetaFunction();
+    fn.setFieldName(name);
+    fn.setFormula(formula);
+    fn.setValueType(IValueMeta.TYPE_STRING);
+    fn.setValueLength(-1);
+    fn.setValuePrecision(-1);
+    return fn;
+  }
+
+  /** Wire a single output IRowSet and return it (for result capture). */
+  private static IRowSet attachOutputRowSet(Janino janino) {
+    IRowSet output = mock(IRowSet.class, Mockito.RETURNS_MOCKS);
+    when(output.putRow(any(IRowMeta.class), 
any(Object[].class))).thenReturn(true);
+    when(output.isDone()).thenReturn(false);
+    when(output.getRowWait(any(Long.class), 
any(TimeUnit.class))).thenReturn(null);
+    janino.addRowSetToOutputRowSets(output);
+    return output;
+  }
+
+  // ------------------------------------------------------------------ 
processRow: no input
+
+  @Test
+  void processRow_noInput_returnsFalse() throws HopException {
+    JaninoMeta meta = new JaninoMeta();
+    Janino janino = buildSpy(meta);
+    doReturn(null).when(janino).getRow();
+
+    assertFalse(janino.processRow());
+  }
+
+  // ------------------------------------------------------------------ 
processRow: arithmetic
+
+  @Test
+  void processRow_integerArithmetic_appendsLongResult() throws HopException {
+    JaninoMeta meta = new JaninoMeta();
+    meta.getFunctions().add(intFn("result", "1 + 2"));
+
+    RowMeta inputMeta = new RowMeta();
+    inputMeta.addValueMeta(new ValueMetaString("dummy"));
+
+    Janino janino = buildSpy(meta);
+    doReturn(new Object[] {"x"}).doReturn(null).when(janino).getRow();
+    doReturn(inputMeta).when(janino).getInputRowMeta();
+
+    IRowSet output = attachOutputRowSet(janino);
+    janino.init();
+    assertTrue(janino.processRow());
+    janino.processRow(); // drain (null row)
+
+    ArgumentCaptor<Object[]> rowCaptor = 
ArgumentCaptor.forClass(Object[].class);
+    verify(output).putRow(any(IRowMeta.class), rowCaptor.capture());
+    Object[] row = rowCaptor.getValue();
+    assertNotNull(row);
+    assertEquals("x", row[0]);
+    assertEquals(3L, row[1]); // Integer 3 promoted to Long
+  }
+
+  // ------------------------------------------------------------------ 
processRow: String result
+
+  @Test
+  void processRow_stringResult_appendsString() throws HopException {
+    JaninoMeta meta = new JaninoMeta();
+    meta.getFunctions().add(strFn("greeting", "\"hello\""));
+
+    RowMeta inputMeta = new RowMeta();
+    inputMeta.addValueMeta(new ValueMetaInteger("id"));
+
+    Janino janino = buildSpy(meta);
+    doReturn(new Object[] {1L}).doReturn(null).when(janino).getRow();
+    doReturn(inputMeta).when(janino).getInputRowMeta();
+
+    IRowSet output = attachOutputRowSet(janino);
+    janino.init();
+    assertTrue(janino.processRow());
+
+    ArgumentCaptor<Object[]> rowCaptor = 
ArgumentCaptor.forClass(Object[].class);
+    verify(output).putRow(any(IRowMeta.class), rowCaptor.capture());
+    assertEquals("hello", rowCaptor.getValue()[1]);
+  }
+
+  // ------------------------------------------------------------------ 
processRow: input field
+  // reference
+
+  @Test
+  void processRow_referencesInputField_computesCorrectly() throws HopException 
{
+    JaninoMeta meta = new JaninoMeta();
+    meta.getFunctions().add(intFn("doubled", "n * 2"));
+
+    RowMeta inputMeta = new RowMeta();
+    inputMeta.addValueMeta(new ValueMetaInteger("n"));
+
+    Janino janino = buildSpy(meta);
+    doReturn(new Object[] {5L}).doReturn(null).when(janino).getRow();
+    doReturn(inputMeta).when(janino).getInputRowMeta();
+
+    IRowSet output = attachOutputRowSet(janino);
+    janino.init();
+    assertTrue(janino.processRow());
+
+    ArgumentCaptor<Object[]> rowCaptor = 
ArgumentCaptor.forClass(Object[].class);
+    verify(output).putRow(any(IRowMeta.class), rowCaptor.capture());
+    assertEquals(10L, rowCaptor.getValue()[1]); // 5 * 2 = 10
+  }
+
+  // ------------------------------------------------------------------ 
processRow: null formula
+  // result
+
+  @Test
+  void processRow_nullResult_appendsNull() throws HopException {
+    JaninoMeta meta = new JaninoMeta();
+    JaninoMetaFunction fn = intFn("x", "null");
+    fn.setValueType(IValueMeta.TYPE_STRING);
+    meta.getFunctions().add(fn);
+
+    RowMeta inputMeta = new RowMeta();
+    inputMeta.addValueMeta(new ValueMetaInteger("id"));
+
+    Janino janino = buildSpy(meta);
+    doReturn(new Object[] {1L}).doReturn(null).when(janino).getRow();
+    doReturn(inputMeta).when(janino).getInputRowMeta();
+
+    IRowSet output = attachOutputRowSet(janino);
+    janino.init();
+    assertTrue(janino.processRow());
+
+    ArgumentCaptor<Object[]> rowCaptor = 
ArgumentCaptor.forClass(Object[].class);
+    verify(output).putRow(any(IRowMeta.class), rowCaptor.capture());
+    // null result at index 1
+    assertFalse(rowCaptor.getValue().length == 0);
+  }
+
+  // ------------------------------------------------------------------ target 
version ≥ 8: static
+  // interface method
+
+  @Test
+  void processRow_target8_staticInterfaceMethod_succeeds() throws HopException 
{
+    JaninoMeta meta = new JaninoMeta();
+    meta.setJavaTargetVersion(8);
+    // Comparator.naturalOrder() calls a static interface method (Java 8+)
+    meta.getFunctions()
+        .add(
+            intFn(
+                "cmp",
+                
"java.util.Comparator.naturalOrder().compare(Integer.valueOf(5), 
Integer.valueOf(3))"));
+
+    RowMeta inputMeta = new RowMeta();
+    inputMeta.addValueMeta(new ValueMetaInteger("id"));
+
+    Janino janino = buildSpy(meta);
+    doReturn(new Object[] {1L}).doReturn(null).when(janino).getRow();
+    doReturn(inputMeta).when(janino).getInputRowMeta();
+
+    IRowSet output = attachOutputRowSet(janino);
+    janino.init();
+    assertTrue(janino.processRow());
+
+    ArgumentCaptor<Object[]> rowCaptor = 
ArgumentCaptor.forClass(Object[].class);
+    verify(output).putRow(any(IRowMeta.class), rowCaptor.capture());
+    // naturalOrder().compare(5, 3) > 0
+    assertTrue(((Long) rowCaptor.getValue()[1]) > 0);
+  }
+
+  // ------------------------------------------------------------------ target 
version 6: static
+  // interface method fails
+
+  @Test
+  void processRow_target6_staticInterfaceMethod_throwsHopException() throws 
HopException {
+    JaninoMeta meta = new JaninoMeta();
+    meta.setJavaTargetVersion(6); // below 8 — static interface calls blocked 
by Janino
+    meta.getFunctions()
+        .add(
+            intFn(
+                "cmp",
+                
"java.util.Comparator.naturalOrder().compare(Integer.valueOf(5), 
Integer.valueOf(3))"));
+
+    RowMeta inputMeta = new RowMeta();
+    inputMeta.addValueMeta(new ValueMetaInteger("id"));
+
+    Janino janino = buildSpy(meta);
+    doReturn(new Object[] {1L}).when(janino).getRow();
+    doReturn(inputMeta).when(janino).getInputRowMeta();
+    attachOutputRowSet(janino);
+    janino.init();
+
+    assertThrows(HopException.class, janino::processRow);
+  }
+
+  // ------------------------------------------------------------------ 
error-handling route
+
+  @Test
+  void processRow_badFormula_withErrorHandling_sendsToErrorStream() throws 
HopException {
+    JaninoMeta meta = new JaninoMeta();
+    // Empty field name triggers the "Unable to find field name" exception 
during cook
+    JaninoMetaFunction fn = new JaninoMetaFunction();
+    fn.setFieldName(""); // blank — triggers HopException in calcFields
+    fn.setFormula("1");
+    fn.setValueType(IValueMeta.TYPE_INTEGER);
+    fn.setValueLength(-1);
+    fn.setValuePrecision(-1);
+    meta.getFunctions().add(fn);
+
+    RowMeta inputMeta = new RowMeta();
+    inputMeta.addValueMeta(new ValueMetaInteger("id"));
+
+    Janino janino = buildSpy(meta);
+    doReturn(new Object[] {1L}).when(janino).getRow();
+    doReturn(inputMeta).when(janino).getInputRowMeta();
+
+    // Enable error handling so the transform routes errors instead of 
re-throwing.
+    // Set up the minimal TransformErrorMeta the BaseTransform.handlePutError 
path needs.
+    when(helper.transformMeta.isDoingErrorHandling()).thenReturn(true);
+    TransformErrorMeta errorMeta = mock(TransformErrorMeta.class);
+    when(helper.transformMeta.getTransformErrorMeta()).thenReturn(errorMeta);
+    when(errorMeta.getErrorRowMeta(any())).thenReturn(new RowMeta());
+
+    attachOutputRowSet(janino);
+    janino.init();
+
+    // Should return true (row consumed by error routing) rather than throwing 
HopException
+    assertTrue(janino.processRow());
+  }
+
+  // ------------------------------------------------------------------ 
multiple rows
+
+  @Test
+  void processRow_multipleRows_computesEachCorrectly() throws HopException {
+    JaninoMeta meta = new JaninoMeta();
+    meta.getFunctions().add(intFn("sq", "n * n"));
+
+    RowMeta inputMeta = new RowMeta();
+    inputMeta.addValueMeta(new ValueMetaInteger("n"));
+
+    Janino janino = buildSpy(meta);
+    doReturn(new Object[] {2L}).doReturn(new Object[] 
{3L}).doReturn(null).when(janino).getRow();
+    doReturn(inputMeta).when(janino).getInputRowMeta();
+
+    IRowSet output = attachOutputRowSet(janino);
+    janino.init();
+
+    assertTrue(janino.processRow()); // row 1
+    assertTrue(janino.processRow()); // row 2
+    assertFalse(janino.processRow()); // end
+
+    ArgumentCaptor<Object[]> rowCaptor = 
ArgumentCaptor.forClass(Object[].class);
+    verify(output, Mockito.times(2)).putRow(any(IRowMeta.class), 
rowCaptor.capture());
+
+    assertEquals(4L, rowCaptor.getAllValues().get(0)[1]); // 2*2
+    assertEquals(9L, rowCaptor.getAllValues().get(1)[1]); // 3*3
+  }
+}
diff --git 
a/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/javafilter/JavaFilterMetaTest.java
 
b/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/javafilter/JavaFilterMetaTest.java
new file mode 100644
index 0000000000..2262c835be
--- /dev/null
+++ 
b/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/javafilter/JavaFilterMetaTest.java
@@ -0,0 +1,494 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.hop.pipeline.transforms.javafilter;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.ArrayList;
+import java.util.List;
+import org.apache.hop.core.ICheckResult;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.row.RowMeta;
+import org.apache.hop.core.row.value.ValueMetaInteger;
+import org.apache.hop.core.row.value.ValueMetaString;
+import org.apache.hop.core.xml.XmlHandler;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.metadata.serializer.memory.MemoryMetadataProvider;
+import org.apache.hop.metadata.serializer.xml.XmlMetadataUtil;
+import org.apache.hop.pipeline.PipelineMeta;
+import org.apache.hop.pipeline.transform.TransformMeta;
+import org.apache.hop.pipeline.transform.stream.IStream;
+import org.junit.jupiter.api.Test;
+
+class JavaFilterMetaTest {
+
+  // ------------------------------------------------------------------ 
serialization
+
+  @Test
+  void testDeserialize() throws Exception {
+    // Deserialize directly (without round-trip getXml(), which would call
+    // convertIOMetaToTransformNames() and reset the stream names to "").
+    var document =
+        
XmlHandler.loadXmlFile(JavaFilterMetaTest.class.getResourceAsStream("/java-filter.xml"));
+    var node = XmlHandler.getSubNode(document, TransformMeta.XML_TAG);
+    JavaFilterMeta meta =
+        XmlMetadataUtil.deSerializeFromXml(
+            node, JavaFilterMeta.class, new MemoryMetadataProvider());
+
+    assertEquals("amount > 0", meta.getCondition());
+    assertEquals("positiveRows", meta.getTrueTransform());
+    assertEquals("negativeRows", meta.getFalseTransform());
+  }
+
+  @Test
+  void testGetXml_conditionRoundTrips() throws Exception {
+    // getXml() calls convertIOMetaToTransformNames() which resets stream 
names in tests
+    // (streams have no linked TransformMeta) — just verify condition survives 
the round-trip.
+    JavaFilterMeta meta = new JavaFilterMeta();
+    meta.setCondition("x > 0");
+    String xml =
+        XmlHandler.openTag(TransformMeta.XML_TAG)
+            + meta.getXml()
+            + XmlHandler.closeTag(TransformMeta.XML_TAG);
+
+    var document = XmlHandler.loadXmlString(xml);
+    var node = XmlHandler.getSubNode(document, TransformMeta.XML_TAG);
+    JavaFilterMeta copy =
+        XmlMetadataUtil.deSerializeFromXml(
+            node, JavaFilterMeta.class, new MemoryMetadataProvider());
+    assertEquals("x > 0", copy.getCondition());
+  }
+
+  // ------------------------------------------------------------------ 
getters / setters
+
+  @Test
+  void setGetCondition() {
+    JavaFilterMeta meta = new JavaFilterMeta();
+    assertNull(meta.getCondition());
+    meta.setCondition("x > 0");
+    assertEquals("x > 0", meta.getCondition());
+  }
+
+  @Test
+  void setGetTrueTransform() {
+    JavaFilterMeta meta = new JavaFilterMeta();
+    assertNull(meta.getTrueTransform());
+    meta.setTrueTransform("yes");
+    assertEquals("yes", meta.getTrueTransform());
+  }
+
+  @Test
+  void setGetFalseTransform() {
+    JavaFilterMeta meta = new JavaFilterMeta();
+    assertNull(meta.getFalseTransform());
+    meta.setFalseTransform("no");
+    assertEquals("no", meta.getFalseTransform());
+  }
+
+  // ------------------------------------------------------------------ 
setDefault
+
+  @Test
+  void setDefault_setsConditionToTrue() {
+    JavaFilterMeta meta = new JavaFilterMeta();
+    meta.setDefault();
+    assertEquals("true", meta.getCondition());
+  }
+
+  // ------------------------------------------------------------------ clone
+
+  @Test
+  void clone_returnsDifferentInstance() {
+    JavaFilterMeta meta = new JavaFilterMeta();
+    meta.setCondition("n > 0");
+    meta.setTrueTransform("yes");
+    meta.setFalseTransform("no");
+
+    JavaFilterMeta cloned = (JavaFilterMeta) meta.clone();
+    assertNotSame(meta, cloned);
+    assertEquals("n > 0", cloned.getCondition());
+  }
+
+  // ------------------------------------------------------------------ 
hashCode
+
+  @Test
+  void hashCode_sameCondition_sameHashCode() {
+    JavaFilterMeta a = new JavaFilterMeta();
+    a.setCondition("x > 0");
+    JavaFilterMeta b = new JavaFilterMeta();
+    b.setCondition("x > 0");
+    assertEquals(a.hashCode(), b.hashCode());
+  }
+
+  // ------------------------------------------------------------------
+  // convertIOMetaToTransformNames
+
+  @Test
+  void convertIOMetaToTransformNames_nullStreams_setsEmptyStrings() {
+    JavaFilterMeta meta = new JavaFilterMeta();
+    // streams start with null TransformMeta → getTransformName() returns null 
→ NVL → ""
+    meta.convertIOMetaToTransformNames();
+    assertEquals("", meta.getTrueTransform());
+    assertEquals("", meta.getFalseTransform());
+  }
+
+  @Test
+  void convertIOMetaToTransformNames_withNames_copiesNames() {
+    JavaFilterMeta meta = new JavaFilterMeta();
+    TransformMeta trueMeta = mock(TransformMeta.class);
+    when(trueMeta.getName()).thenReturn("trueTarget");
+    TransformMeta falseMeta = mock(TransformMeta.class);
+    when(falseMeta.getName()).thenReturn("falseTarget");
+
+    List<IStream> streams = meta.getTransformIOMeta().getTargetStreams();
+    streams.get(0).setTransformMeta(trueMeta);
+    streams.get(1).setTransformMeta(falseMeta);
+
+    meta.convertIOMetaToTransformNames();
+    assertEquals("trueTarget", meta.getTrueTransform());
+    assertEquals("falseTarget", meta.getFalseTransform());
+  }
+
+  // ------------------------------------------------------------------
+  // searchInfoAndTargetTransforms
+
+  @Test
+  void searchInfoAndTargetTransforms_findsMatchingTransforms() {
+    JavaFilterMeta meta = new JavaFilterMeta();
+    meta.setTrueTransform("yes");
+    meta.setFalseTransform("no");
+
+    TransformMeta yesMeta = mock(TransformMeta.class);
+    when(yesMeta.getName()).thenReturn("yes");
+    TransformMeta noMeta = mock(TransformMeta.class);
+    when(noMeta.getName()).thenReturn("no");
+
+    meta.searchInfoAndTargetTransforms(List.of(yesMeta, noMeta));
+
+    List<IStream> streams = meta.getTransformIOMeta().getTargetStreams();
+    assertEquals(yesMeta, streams.get(0).getTransformMeta());
+    assertEquals(noMeta, streams.get(1).getTransformMeta());
+  }
+
+  @Test
+  void searchInfoAndTargetTransforms_noMatch_setsNull() {
+    JavaFilterMeta meta = new JavaFilterMeta();
+    meta.setTrueTransform("nonexistent");
+    meta.setFalseTransform("alsoMissing");
+
+    TransformMeta other = mock(TransformMeta.class);
+    when(other.getName()).thenReturn("other");
+
+    meta.searchInfoAndTargetTransforms(List.of(other));
+
+    List<IStream> streams = meta.getTransformIOMeta().getTargetStreams();
+    assertNull(streams.get(0).getTransformMeta());
+    assertNull(streams.get(1).getTransformMeta());
+  }
+
+  // ------------------------------------------------------------------ 
getTransformIOMeta
+
+  @Test
+  void getTransformIOMeta_lazyInit_returnsSameInstance() {
+    JavaFilterMeta meta = new JavaFilterMeta();
+    var io1 = meta.getTransformIOMeta();
+    var io2 = meta.getTransformIOMeta();
+    assertNotNull(io1);
+    assertEquals(2, io1.getTargetStreams().size());
+  }
+
+  // ------------------------------------------------------------------ 
resetTransformIoMeta (no-op)
+
+  @Test
+  void resetTransformIoMeta_doesNotClearExistingIo() {
+    JavaFilterMeta meta = new JavaFilterMeta();
+    var io = meta.getTransformIOMeta();
+    meta.resetTransformIoMeta();
+    assertEquals(io, meta.getTransformIOMeta());
+  }
+
+  // ------------------------------------------------------------------
+  // excludeFromCopyDistributeVerification
+
+  @Test
+  void excludeFromCopyDistributeVerification_returnsTrue() {
+    assertTrue(new JavaFilterMeta().excludeFromCopyDistributeVerification());
+  }
+
+  // ------------------------------------------------------------------ check: 
target stream
+  // combinations
+
+  @Test
+  void check_bothTargetsSpecified_addsOk() {
+    JavaFilterMeta meta = new JavaFilterMeta();
+    meta.setCondition("x > 0");
+
+    TransformMeta trueMeta = mock(TransformMeta.class);
+    when(trueMeta.getName()).thenReturn("yes");
+    TransformMeta falseMeta = mock(TransformMeta.class);
+    when(falseMeta.getName()).thenReturn("no");
+    
meta.getTransformIOMeta().getTargetStreams().get(0).setTransformMeta(trueMeta);
+    
meta.getTransformIOMeta().getTargetStreams().get(1).setTransformMeta(falseMeta);
+
+    RowMeta prev = new RowMeta();
+    prev.addValueMeta(new ValueMetaInteger("x"));
+
+    List<ICheckResult> remarks = new ArrayList<>();
+    meta.check(
+        remarks,
+        mock(PipelineMeta.class),
+        mock(TransformMeta.class),
+        prev,
+        new String[] {"in"},
+        new String[] {"yes", "no"},
+        mock(IRowMeta.class),
+        null,
+        mock(IHopMetadataProvider.class));
+
+    long okCount = remarks.stream().filter(r -> r.getType() == 
ICheckResult.TYPE_RESULT_OK).count();
+    assertTrue(okCount >= 1);
+  }
+
+  @Test
+  void check_neitherTargetSpecified_addsOk() {
+    JavaFilterMeta meta = new JavaFilterMeta();
+    meta.setCondition("true");
+    // both streams have null TransformMeta → getTransformName() returns null
+
+    RowMeta prev = new RowMeta();
+    prev.addValueMeta(new ValueMetaString("x"));
+
+    List<ICheckResult> remarks = new ArrayList<>();
+    meta.check(
+        remarks,
+        mock(PipelineMeta.class),
+        mock(TransformMeta.class),
+        prev,
+        new String[] {"in"},
+        new String[0],
+        mock(IRowMeta.class),
+        null,
+        mock(IHopMetadataProvider.class));
+
+    assertTrue(remarks.stream().anyMatch(r -> r.getType() == 
ICheckResult.TYPE_RESULT_OK));
+  }
+
+  @Test
+  void check_onlyOneTrueTargetSpecified_addsOk() {
+    JavaFilterMeta meta = new JavaFilterMeta();
+    meta.setCondition("true");
+
+    TransformMeta trueMeta = mock(TransformMeta.class);
+    when(trueMeta.getName()).thenReturn("yes");
+    
meta.getTransformIOMeta().getTargetStreams().get(0).setTransformMeta(trueMeta);
+    // false stream stays null
+
+    List<ICheckResult> remarks = new ArrayList<>();
+    meta.check(
+        remarks,
+        mock(PipelineMeta.class),
+        mock(TransformMeta.class),
+        new RowMeta(),
+        new String[] {"in"},
+        new String[] {"yes"},
+        mock(IRowMeta.class),
+        null,
+        mock(IHopMetadataProvider.class));
+
+    // The "else" branch adds an OK result even when only one target is set
+    assertTrue(remarks.stream().anyMatch(r -> r.getType() == 
ICheckResult.TYPE_RESULT_OK));
+  }
+
+  @Test
+  void check_trueTargetNotInOutput_addsError() {
+    JavaFilterMeta meta = new JavaFilterMeta();
+    meta.setCondition("true");
+
+    TransformMeta trueMeta = mock(TransformMeta.class);
+    when(trueMeta.getName()).thenReturn("missingTarget");
+    
meta.getTransformIOMeta().getTargetStreams().get(0).setTransformMeta(trueMeta);
+
+    List<ICheckResult> remarks = new ArrayList<>();
+    meta.check(
+        remarks,
+        mock(PipelineMeta.class),
+        mock(TransformMeta.class),
+        new RowMeta(),
+        new String[] {"in"},
+        new String[] {"otherTarget"},
+        mock(IRowMeta.class),
+        null,
+        mock(IHopMetadataProvider.class));
+
+    assertTrue(remarks.stream().anyMatch(r -> r.getType() == 
ICheckResult.TYPE_RESULT_ERROR));
+  }
+
+  @Test
+  void check_falseTargetNotInOutput_addsError() {
+    JavaFilterMeta meta = new JavaFilterMeta();
+    meta.setCondition("true");
+
+    TransformMeta falseMeta = mock(TransformMeta.class);
+    when(falseMeta.getName()).thenReturn("missingFalse");
+    
meta.getTransformIOMeta().getTargetStreams().get(1).setTransformMeta(falseMeta);
+
+    List<ICheckResult> remarks = new ArrayList<>();
+    meta.check(
+        remarks,
+        mock(PipelineMeta.class),
+        mock(TransformMeta.class),
+        new RowMeta(),
+        new String[] {"in"},
+        new String[] {"otherTarget"},
+        mock(IRowMeta.class),
+        null,
+        mock(IHopMetadataProvider.class));
+
+    assertTrue(remarks.stream().anyMatch(r -> r.getType() == 
ICheckResult.TYPE_RESULT_ERROR));
+  }
+
+  @Test
+  void check_emptyCondition_addsError() {
+    JavaFilterMeta meta = new JavaFilterMeta();
+    meta.setCondition("");
+
+    List<ICheckResult> remarks = new ArrayList<>();
+    meta.check(
+        remarks,
+        mock(PipelineMeta.class),
+        mock(TransformMeta.class),
+        new RowMeta(),
+        new String[] {"in"},
+        new String[0],
+        mock(IRowMeta.class),
+        null,
+        mock(IHopMetadataProvider.class));
+
+    assertTrue(remarks.stream().anyMatch(r -> r.getType() == 
ICheckResult.TYPE_RESULT_ERROR));
+  }
+
+  @Test
+  void check_nullCondition_addsError() {
+    JavaFilterMeta meta = new JavaFilterMeta();
+    // condition stays null
+
+    List<ICheckResult> remarks = new ArrayList<>();
+    meta.check(
+        remarks,
+        mock(PipelineMeta.class),
+        mock(TransformMeta.class),
+        new RowMeta(),
+        new String[] {"in"},
+        new String[0],
+        mock(IRowMeta.class),
+        null,
+        mock(IHopMetadataProvider.class));
+
+    assertTrue(remarks.stream().anyMatch(r -> r.getType() == 
ICheckResult.TYPE_RESULT_ERROR));
+  }
+
+  @Test
+  void check_prevFieldsEmpty_addsError() {
+    JavaFilterMeta meta = new JavaFilterMeta();
+    meta.setCondition("true");
+
+    List<ICheckResult> remarks = new ArrayList<>();
+    meta.check(
+        remarks,
+        mock(PipelineMeta.class),
+        mock(TransformMeta.class),
+        new RowMeta(),
+        new String[] {"in"},
+        new String[0],
+        mock(IRowMeta.class),
+        null,
+        mock(IHopMetadataProvider.class));
+
+    assertTrue(remarks.stream().anyMatch(r -> r.getType() == 
ICheckResult.TYPE_RESULT_ERROR));
+  }
+
+  @Test
+  void check_prevFieldsNull_addsError() {
+    JavaFilterMeta meta = new JavaFilterMeta();
+    meta.setCondition("true");
+
+    List<ICheckResult> remarks = new ArrayList<>();
+    meta.check(
+        remarks,
+        mock(PipelineMeta.class),
+        mock(TransformMeta.class),
+        null,
+        new String[] {"in"},
+        new String[0],
+        mock(IRowMeta.class),
+        null,
+        mock(IHopMetadataProvider.class));
+
+    assertTrue(remarks.stream().anyMatch(r -> r.getType() == 
ICheckResult.TYPE_RESULT_ERROR));
+  }
+
+  @Test
+  void check_noInputTransforms_addsError() {
+    JavaFilterMeta meta = new JavaFilterMeta();
+    meta.setCondition("true");
+
+    RowMeta prev = new RowMeta();
+    prev.addValueMeta(new ValueMetaString("x"));
+
+    List<ICheckResult> remarks = new ArrayList<>();
+    meta.check(
+        remarks,
+        mock(PipelineMeta.class),
+        mock(TransformMeta.class),
+        prev,
+        new String[0],
+        new String[0],
+        mock(IRowMeta.class),
+        null,
+        mock(IHopMetadataProvider.class));
+
+    assertTrue(remarks.stream().anyMatch(r -> r.getType() == 
ICheckResult.TYPE_RESULT_ERROR));
+  }
+
+  @Test
+  void check_withInputTransforms_addsOk() {
+    JavaFilterMeta meta = new JavaFilterMeta();
+    meta.setCondition("true");
+
+    RowMeta prev = new RowMeta();
+    prev.addValueMeta(new ValueMetaString("x"));
+
+    List<ICheckResult> remarks = new ArrayList<>();
+    meta.check(
+        remarks,
+        mock(PipelineMeta.class),
+        mock(TransformMeta.class),
+        prev,
+        new String[] {"upstream"},
+        new String[0],
+        mock(IRowMeta.class),
+        null,
+        mock(IHopMetadataProvider.class));
+
+    assertTrue(remarks.stream().anyMatch(r -> r.getType() == 
ICheckResult.TYPE_RESULT_OK));
+  }
+}
diff --git 
a/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/javafilter/JavaFilterTest.java
 
b/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/javafilter/JavaFilterTest.java
new file mode 100644
index 0000000000..aa84e8aadb
--- /dev/null
+++ 
b/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/javafilter/JavaFilterTest.java
@@ -0,0 +1,257 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.hop.pipeline.transforms.javafilter;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.util.concurrent.TimeUnit;
+import org.apache.hop.core.IRowSet;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.logging.HopLogStore;
+import org.apache.hop.core.logging.ILoggingObject;
+import org.apache.hop.core.plugins.PluginRegistry;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.row.RowMeta;
+import org.apache.hop.core.row.value.ValueMetaInteger;
+import org.apache.hop.core.row.value.ValueMetaPlugin;
+import org.apache.hop.core.row.value.ValueMetaPluginType;
+import org.apache.hop.core.row.value.ValueMetaString;
+import org.apache.hop.pipeline.transforms.mock.TransformMockHelper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.Mockito;
+
+/**
+ * Unit tests for {@link JavaFilter#processRow()} covering filter-true, 
filter-false, field
+ * references, non-boolean results and compile failures.
+ */
+class JavaFilterTest {
+
+  private TransformMockHelper<JavaFilterMeta, JavaFilterData> helper;
+
+  @BeforeAll
+  static void initPlugins() throws Exception {
+    HopLogStore.init();
+    PluginRegistry registry = PluginRegistry.getInstance();
+    String[] valueMetaClasses = {
+      org.apache.hop.core.row.value.ValueMetaString.class.getName(),
+      org.apache.hop.core.row.value.ValueMetaInteger.class.getName(),
+      org.apache.hop.core.row.value.ValueMetaDate.class.getName(),
+      org.apache.hop.core.row.value.ValueMetaNumber.class.getName()
+    };
+    for (String cls : valueMetaClasses) {
+      registry.registerPluginClass(cls, ValueMetaPluginType.class, 
ValueMetaPlugin.class);
+    }
+  }
+
+  @BeforeEach
+  void setUp() {
+    helper =
+        new TransformMockHelper<>("JavaFilter TEST", JavaFilterMeta.class, 
JavaFilterData.class);
+    when(helper.logChannelFactory.create(any(), any(ILoggingObject.class)))
+        .thenReturn(helper.iLogChannel);
+    when(helper.pipeline.isRunning()).thenReturn(true);
+  }
+
+  @AfterEach
+  void tearDown() {
+    helper.cleanUp();
+  }
+
+  // ------------------------------------------------------------------ helpers
+
+  private JavaFilter buildSpy(JavaFilterMeta meta) {
+    return Mockito.spy(
+        new JavaFilter(
+            helper.transformMeta,
+            meta,
+            new JavaFilterData(),
+            0,
+            helper.pipelineMeta,
+            helper.pipeline));
+  }
+
+  private static IRowSet attachOutputRowSet(JavaFilter jf) {
+    IRowSet output = mock(IRowSet.class, Mockito.RETURNS_MOCKS);
+    when(output.putRow(any(IRowMeta.class), 
any(Object[].class))).thenReturn(true);
+    when(output.isDone()).thenReturn(false);
+    when(output.getRowWait(any(Long.class), 
any(TimeUnit.class))).thenReturn(null);
+    jf.addRowSetToOutputRowSets(output);
+    return output;
+  }
+
+  // ------------------------------------------------------------------ no 
input
+
+  @Test
+  void processRow_noInput_returnsFalse() throws HopException {
+    JavaFilterMeta meta = new JavaFilterMeta();
+    JavaFilter jf = buildSpy(meta);
+    doReturn(null).when(jf).getRow();
+
+    assertFalse(jf.processRow());
+  }
+
+  // ------------------------------------------------------------------ 
constant true / false
+
+  @Test
+  void processRow_conditionTrue_rowPassesThrough() throws HopException {
+    JavaFilterMeta meta = new JavaFilterMeta();
+    meta.setCondition("true");
+
+    RowMeta inputMeta = new RowMeta();
+    inputMeta.addValueMeta(new ValueMetaString("name"));
+
+    JavaFilter jf = buildSpy(meta);
+    doReturn(new Object[] {"alice"}).doReturn(null).when(jf).getRow();
+    doReturn(inputMeta).when(jf).getInputRowMeta();
+
+    IRowSet output = attachOutputRowSet(jf);
+    jf.init();
+    assertTrue(jf.processRow());
+    jf.processRow(); // drain null
+
+    ArgumentCaptor<Object[]> captor = ArgumentCaptor.forClass(Object[].class);
+    verify(output).putRow(any(IRowMeta.class), captor.capture());
+    org.junit.jupiter.api.Assertions.assertArrayEquals(new Object[] {"alice"}, 
captor.getValue());
+  }
+
+  @Test
+  void processRow_conditionFalse_rowDropped() throws HopException {
+    JavaFilterMeta meta = new JavaFilterMeta();
+    meta.setCondition("false");
+
+    RowMeta inputMeta = new RowMeta();
+    inputMeta.addValueMeta(new ValueMetaString("name"));
+
+    JavaFilter jf = buildSpy(meta);
+    doReturn(new Object[] {"alice"}).doReturn(null).when(jf).getRow();
+    doReturn(inputMeta).when(jf).getInputRowMeta();
+
+    IRowSet output = attachOutputRowSet(jf);
+    jf.init();
+    assertTrue(jf.processRow());
+    jf.processRow();
+
+    // No row should reach the output
+    verify(output, Mockito.never()).putRow(any(IRowMeta.class), 
any(Object[].class));
+  }
+
+  // ------------------------------------------------------------------ 
input-field reference
+
+  @Test
+  void processRow_conditionUsesInputField_filtersCorrectly() throws 
HopException {
+    JavaFilterMeta meta = new JavaFilterMeta();
+    meta.setCondition("amount > 0");
+
+    RowMeta inputMeta = new RowMeta();
+    inputMeta.addValueMeta(new ValueMetaInteger("amount"));
+
+    JavaFilter jf = buildSpy(meta);
+    // Row with amount = 5 → passes; amount = -3 → dropped
+    doReturn(new Object[] {5L}).doReturn(new Object[] 
{-3L}).doReturn(null).when(jf).getRow();
+    doReturn(inputMeta).when(jf).getInputRowMeta();
+
+    IRowSet output = attachOutputRowSet(jf);
+    jf.init();
+    jf.processRow(); // amount=5  → true, sent to output
+    jf.processRow(); // amount=-3 → false, dropped
+    jf.processRow(); // null      → done
+
+    verify(output, Mockito.times(1)).putRow(any(IRowMeta.class), 
any(Object[].class));
+  }
+
+  // ------------------------------------------------------------------ 
non-boolean result throws
+
+  @Test
+  void processRow_nonBooleanResult_throwsHopException() throws HopException {
+    JavaFilterMeta meta = new JavaFilterMeta();
+    meta.setCondition("\"not a boolean\"");
+
+    RowMeta inputMeta = new RowMeta();
+    inputMeta.addValueMeta(new ValueMetaInteger("id"));
+
+    JavaFilter jf = buildSpy(meta);
+    doReturn(new Object[] {1L}).when(jf).getRow();
+    doReturn(inputMeta).when(jf).getInputRowMeta();
+
+    attachOutputRowSet(jf);
+    jf.init();
+
+    assertThrows(HopException.class, jf::processRow);
+  }
+
+  // ------------------------------------------------------------------ 
compile error throws
+
+  @Test
+  void processRow_invalidConditionSyntax_throwsHopException() throws 
HopException {
+    JavaFilterMeta meta = new JavaFilterMeta();
+    meta.setCondition("this is not valid java at all !!!");
+
+    RowMeta inputMeta = new RowMeta();
+    inputMeta.addValueMeta(new ValueMetaInteger("id"));
+
+    JavaFilter jf = buildSpy(meta);
+    doReturn(new Object[] {1L}).when(jf).getRow();
+    doReturn(inputMeta).when(jf).getInputRowMeta();
+
+    attachOutputRowSet(jf);
+    jf.init();
+
+    assertThrows(HopException.class, jf::processRow);
+  }
+
+  // ------------------------------------------------------------------ 
multiple rows
+
+  @Test
+  void processRow_multipleRows_returnsCorrectly() throws HopException {
+    JavaFilterMeta meta = new JavaFilterMeta();
+    meta.setCondition("n >= 0");
+
+    RowMeta inputMeta = new RowMeta();
+    inputMeta.addValueMeta(new ValueMetaInteger("n"));
+
+    JavaFilter jf = buildSpy(meta);
+    doReturn(new Object[] {1L})
+        .doReturn(new Object[] {-1L})
+        .doReturn(new Object[] {0L})
+        .doReturn(null)
+        .when(jf)
+        .getRow();
+    doReturn(inputMeta).when(jf).getInputRowMeta();
+
+    IRowSet output = attachOutputRowSet(jf);
+    jf.init();
+
+    assertTrue(jf.processRow()); // n=1  → true
+    assertTrue(jf.processRow()); // n=-1 → false (dropped)
+    assertTrue(jf.processRow()); // n=0  → true
+    assertFalse(jf.processRow()); // null → done
+
+    // Only 2 rows should pass (n=1 and n=0)
+    verify(output, Mockito.times(2)).putRow(any(IRowMeta.class), 
any(Object[].class));
+  }
+}
diff --git 
a/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/userdefinedjavaclass/FieldHelperTest.java
 
b/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/userdefinedjavaclass/FieldHelperTest.java
index ef58fca2d0..9271c00128 100644
--- 
a/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/userdefinedjavaclass/FieldHelperTest.java
+++ 
b/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/userdefinedjavaclass/FieldHelperTest.java
@@ -19,17 +19,22 @@ package 
org.apache.hop.pipeline.transforms.userdefinedjavaclass;
 
 import static org.junit.jupiter.api.Assertions.assertArrayEquals;
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.mockito.ArgumentMatchers.anyString;
 import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.mock;
 
+import java.math.BigDecimal;
 import java.net.InetAddress;
 import java.sql.Timestamp;
+import java.util.Date;
 import org.apache.hop.core.exception.HopValueException;
 import org.apache.hop.core.logging.LogChannel;
 import org.apache.hop.core.row.IRowMeta;
 import org.apache.hop.core.row.IValueMeta;
+import org.apache.hop.core.row.RowMeta;
 import org.apache.hop.core.row.value.ValueMetaBigNumber;
 import org.apache.hop.core.row.value.ValueMetaBinary;
 import org.apache.hop.core.row.value.ValueMetaBoolean;
@@ -286,4 +291,159 @@ class FieldHelperTest {
 
     assertArrayEquals(new byte[] {0, 1, 2}, (byte[]) data[0]);
   }
+
+  // ------------------------------------------------------------------ 
constructor failure
+
+  @Test
+  void constructor_fieldNotFound_throwsIllegalArgumentException() {
+    RowMeta rowMeta = new RowMeta();
+    rowMeta.addValueMeta(new ValueMetaString("existing"));
+    assertThrows(IllegalArgumentException.class, () -> new 
FieldHelper(rowMeta, "missing"));
+  }
+
+  // ------------------------------------------------------------------ typed 
getters
+
+  @Test
+  void getObject_returnsRawValue() {
+    ValueMetaString v = new ValueMetaString("name");
+    IRowMeta row = mock(IRowMeta.class);
+    doReturn(v).when(row).searchValueMeta(anyString());
+    doReturn(0).when(row).indexOfValue(anyString());
+
+    Object[] data = {"hello"};
+    assertEquals("hello", new FieldHelper(row, "name").getObject(data));
+  }
+
+  @Test
+  void getBoolean_returnsValue() throws HopValueException {
+    ValueMetaBoolean v = new ValueMetaBoolean("flag");
+    IRowMeta row = mock(IRowMeta.class);
+    doReturn(v).when(row).searchValueMeta(anyString());
+    doReturn(0).when(row).indexOfValue(anyString());
+
+    assertEquals(
+        Boolean.TRUE, new FieldHelper(row, "flag").getBoolean(new Object[] 
{Boolean.TRUE}));
+  }
+
+  @Test
+  void getLong_returnsValue() throws HopValueException {
+    ValueMetaInteger v = new ValueMetaInteger("num");
+    IRowMeta row = mock(IRowMeta.class);
+    doReturn(v).when(row).searchValueMeta(anyString());
+    doReturn(0).when(row).indexOfValue(anyString());
+
+    assertEquals(42L, new FieldHelper(row, "num").getLong(new Object[] {42L}));
+  }
+
+  @Test
+  void getDouble_returnsValue() throws HopValueException {
+    ValueMetaNumber v = new ValueMetaNumber("dbl");
+    IRowMeta row = mock(IRowMeta.class);
+    doReturn(v).when(row).searchValueMeta(anyString());
+    doReturn(0).when(row).indexOfValue(anyString());
+
+    assertEquals(3.14, new FieldHelper(row, "dbl").getDouble(new Object[] 
{3.14}));
+  }
+
+  @Test
+  void getString_returnsValue() throws HopValueException {
+    ValueMetaString v = new ValueMetaString("s");
+    IRowMeta row = mock(IRowMeta.class);
+    doReturn(v).when(row).searchValueMeta(anyString());
+    doReturn(0).when(row).indexOfValue(anyString());
+
+    assertEquals("world", new FieldHelper(row, "s").getString(new Object[] 
{"world"}));
+  }
+
+  @Test
+  void getBigDecimal_returnsValue() throws HopValueException {
+    ValueMetaBigNumber v = new ValueMetaBigNumber("bd");
+    IRowMeta row = mock(IRowMeta.class);
+    doReturn(v).when(row).searchValueMeta(anyString());
+    doReturn(0).when(row).indexOfValue(anyString());
+
+    BigDecimal bd = new BigDecimal("123.456");
+    assertEquals(bd, new FieldHelper(row, "bd").getBigDecimal(new Object[] 
{bd}));
+  }
+
+  @Test
+  void getDate_returnsValue() throws HopValueException {
+    ValueMetaDate v = new ValueMetaDate("dt");
+    IRowMeta row = mock(IRowMeta.class);
+    doReturn(v).when(row).searchValueMeta(anyString());
+    doReturn(0).when(row).indexOfValue(anyString());
+
+    Date d = new Date(0L);
+    assertEquals(d, new FieldHelper(row, "dt").getDate(new Object[] {d}));
+  }
+
+  // ------------------------------------------------------------------ 
getValueMeta / indexOfValue
+
+  @Test
+  void getValueMeta_returnsStoredMeta() {
+    ValueMetaString v = new ValueMetaString("col");
+    IRowMeta row = mock(IRowMeta.class);
+    doReturn(v).when(row).searchValueMeta(anyString());
+    doReturn(0).when(row).indexOfValue(anyString());
+
+    assertNotNull(new FieldHelper(row, "col").getValueMeta());
+    assertEquals(v, new FieldHelper(row, "col").getValueMeta());
+  }
+
+  @Test
+  void indexOfValue_returnsStoredIndex() {
+    ValueMetaString v = new ValueMetaString("col");
+    IRowMeta row = mock(IRowMeta.class);
+    doReturn(v).when(row).searchValueMeta(anyString());
+    doReturn(2).when(row).indexOfValue(anyString());
+
+    assertEquals(2, new FieldHelper(row, "col").indexOfValue());
+  }
+
+  // ------------------------------------------------------------------ 
getAccessor Out variant
+
+  @Test
+  void getAccessor_outVariant_containsFieldsOut() {
+    String accessor = FieldHelper.getAccessor(false, "myField");
+    assertEquals("get(Fields.Out, \"myField\")", accessor);
+  }
+
+  // ------------------------------------------------------------------ 
getGetSignature: invalid
+  // Java identifier → "value"
+
+  @Test
+  void getGetSignature_invalidJavaIdentifier_usesValueAsLocalName() {
+    ValueMetaString v = new ValueMetaString("123invalid");
+    String accessor = FieldHelper.getAccessor(true, "123invalid");
+    String sig = FieldHelper.getGetSignature(accessor, v);
+    // Local variable name should be "value" when name is not a valid Java 
identifier
+    assertEquals("String value = get(Fields.In, 
\"123invalid\").getString(r);", sig);
+  }
+
+  // ------------------------------------------------------------------ 
getNativeDataTypeSimpleName:
+  // remaining types
+
+  @Test
+  void getNativeDataTypeSimpleName_Boolean() {
+    ValueMetaBoolean v = new ValueMetaBoolean();
+    assertEquals("Boolean", FieldHelper.getNativeDataTypeSimpleName(v));
+  }
+
+  @Test
+  void getNativeDataTypeSimpleName_Integer() {
+    ValueMetaInteger v = new ValueMetaInteger();
+    assertEquals("Long", FieldHelper.getNativeDataTypeSimpleName(v));
+  }
+
+  @Test
+  void getNativeDataTypeSimpleName_Number() {
+    ValueMetaNumber v = new ValueMetaNumber();
+    assertEquals("Double", FieldHelper.getNativeDataTypeSimpleName(v));
+  }
+
+  @Test
+  void getNativeDataTypeSimpleName_BigNumber() {
+    ValueMetaBigNumber v = new ValueMetaBigNumber();
+    assertEquals("BigDecimal", FieldHelper.getNativeDataTypeSimpleName(v));
+  }
 }
diff --git 
a/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/userdefinedjavaclass/TransformClassBaseStaticMethodsTest.java
 
b/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/userdefinedjavaclass/TransformClassBaseStaticMethodsTest.java
new file mode 100644
index 0000000000..445aa5fcb4
--- /dev/null
+++ 
b/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/userdefinedjavaclass/TransformClassBaseStaticMethodsTest.java
@@ -0,0 +1,173 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.hop.pipeline.transforms.userdefinedjavaclass;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import java.util.Collections;
+import java.util.List;
+import org.apache.hop.core.plugins.PluginRegistry;
+import org.apache.hop.core.row.IRowMeta;
+import org.apache.hop.core.row.IValueMeta;
+import org.apache.hop.core.row.RowMeta;
+import org.apache.hop.core.row.value.ValueMetaPlugin;
+import org.apache.hop.core.row.value.ValueMetaPluginType;
+import org.apache.hop.core.row.value.ValueMetaString;
+import org.apache.hop.pipeline.transform.ITransformIOMeta;
+import org.apache.hop.pipeline.transform.stream.IStream;
+import 
org.apache.hop.pipeline.transforms.userdefinedjavaclass.UserDefinedJavaClassMeta.FieldInfo;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests the static utility methods on {@link TransformClassBase} that do not 
require a running
+ * transform instance: {@code getInfoTransforms}, {@code getFields}, and 
{@code getTransformIOMeta}.
+ */
+class TransformClassBaseStaticMethodsTest {
+
+  @BeforeAll
+  static void initPlugins() throws Exception {
+    PluginRegistry registry = PluginRegistry.getInstance();
+    registry.registerPluginClass(
+        ValueMetaString.class.getName(), ValueMetaPluginType.class, 
ValueMetaPlugin.class);
+  }
+
+  // ------------------------------------------------------------------ 
getInfoTransforms
+
+  @Test
+  void getInfoTransforms_returnsNull() {
+    assertNull(TransformClassBase.getInfoTransforms());
+  }
+
+  // ------------------------------------------------------------------ 
getFields
+
+  @Test
+  void getFields_appendMode_addsFieldsWithoutClearing() throws Exception {
+    RowMeta row = new RowMeta();
+    row.addValueMeta(new ValueMetaString("existing"));
+
+    FieldInfo fi = new FieldInfo("newCol", IValueMeta.TYPE_STRING, 50, 0);
+
+    TransformClassBase.getFields(false, row, "origin", null, null, null, 
List.of(fi));
+
+    assertEquals(2, row.size());
+    assertEquals("existing", row.getValueMeta(0).getName());
+    assertEquals("newCol", row.getValueMeta(1).getName());
+  }
+
+  @Test
+  void getFields_clearMode_clearsBeforeAdding() throws Exception {
+    RowMeta row = new RowMeta();
+    row.addValueMeta(new ValueMetaString("toBeCleared"));
+
+    FieldInfo fi = new FieldInfo("fresh", IValueMeta.TYPE_STRING, 10, 0);
+
+    TransformClassBase.getFields(true, row, "origin", null, null, null, 
List.of(fi));
+
+    assertEquals(1, row.size());
+    assertEquals("fresh", row.getValueMeta(0).getName());
+  }
+
+  @Test
+  void getFields_emptyList_leavesRowUnchanged() throws Exception {
+    RowMeta row = new RowMeta();
+    row.addValueMeta(new ValueMetaString("keep"));
+
+    TransformClassBase.getFields(false, row, "origin", null, null, null, 
Collections.emptyList());
+
+    assertEquals(1, row.size());
+    assertEquals("keep", row.getValueMeta(0).getName());
+  }
+
+  @Test
+  void getFields_clearWithEmptyList_producesEmptyRow() throws Exception {
+    RowMeta row = new RowMeta();
+    row.addValueMeta(new ValueMetaString("gone"));
+
+    TransformClassBase.getFields(true, row, "origin", null, null, null, 
Collections.emptyList());
+
+    assertEquals(0, row.size());
+  }
+
+  @Test
+  void getFields_setsOriginOnAddedField() throws Exception {
+    RowMeta row = new RowMeta();
+    FieldInfo fi = new FieldInfo("col", IValueMeta.TYPE_STRING, 5, 0);
+
+    TransformClassBase.getFields(false, row, "MyTransform", null, null, null, 
List.of(fi));
+
+    assertEquals("MyTransform", row.getValueMeta(0).getOrigin());
+  }
+
+  // ------------------------------------------------------------------ 
getTransformIOMeta
+
+  @Test
+  void getTransformIOMeta_noInfoNoTarget_returnsEmptyStreams() {
+    UserDefinedJavaClassMeta meta = new UserDefinedJavaClassMeta();
+
+    ITransformIOMeta ioMeta = TransformClassBase.getTransformIOMeta(meta);
+
+    assertNotNull(ioMeta);
+    long infoStreams =
+        ioMeta.getInfoStreams().stream()
+            .filter(s -> s.getStreamType() == IStream.StreamType.INFO)
+            .count();
+    long targetStreams =
+        ioMeta.getTargetStreams().stream()
+            .filter(s -> s.getStreamType() == IStream.StreamType.TARGET)
+            .count();
+    assertEquals(0, infoStreams);
+    assertEquals(0, targetStreams);
+  }
+
+  @Test
+  void getTransformIOMeta_withInfoDefinition_includesInfoStream() {
+    UserDefinedJavaClassMeta meta = new UserDefinedJavaClassMeta();
+    InfoTransformDefinition infoDef = new InfoTransformDefinition();
+    infoDef.setTag("lookup");
+    infoDef.setDescription("Lookup data");
+    meta.getInfoTransformDefinitions().add(infoDef);
+
+    ITransformIOMeta ioMeta = TransformClassBase.getTransformIOMeta(meta);
+
+    IRowMeta infoStreams = null; // unused, just verify count
+    long count =
+        ioMeta.getInfoStreams().stream()
+            .filter(s -> s.getStreamType() == IStream.StreamType.INFO)
+            .count();
+    assertEquals(1, count);
+  }
+
+  @Test
+  void getTransformIOMeta_withTargetDefinition_includesTargetStream() {
+    UserDefinedJavaClassMeta meta = new UserDefinedJavaClassMeta();
+    TargetTransformDefinition targetDef = new TargetTransformDefinition();
+    targetDef.tag = "positive";
+    targetDef.description = "Positive rows";
+    meta.getTargetTransformDefinitions().add(targetDef);
+
+    ITransformIOMeta ioMeta = TransformClassBase.getTransformIOMeta(meta);
+
+    long count =
+        ioMeta.getTargetStreams().stream()
+            .filter(s -> s.getStreamType() == IStream.StreamType.TARGET)
+            .count();
+    assertEquals(1, count);
+  }
+}
diff --git 
a/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/userdefinedjavaclass/TransformDefinitionsTest.java
 
b/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/userdefinedjavaclass/TransformDefinitionsTest.java
new file mode 100644
index 0000000000..6f7dbfea00
--- /dev/null
+++ 
b/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/userdefinedjavaclass/TransformDefinitionsTest.java
@@ -0,0 +1,212 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.hop.pipeline.transforms.userdefinedjavaclass;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.Mockito.mock;
+
+import org.apache.hop.pipeline.transform.TransformMeta;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for the four lightweight definition/parameter value objects: {@link 
TransformDefinition},
+ * {@link InfoTransformDefinition}, {@link TargetTransformDefinition}, and 
{@link UsageParameter}.
+ */
+class TransformDefinitionsTest {
+
+  // ================================================================== 
TransformDefinition
+
+  @Test
+  void transformDefinition_defaultConstructor_emptyStringsNullMeta() {
+    TransformDefinition td = new TransformDefinition();
+    assertEquals("", td.tag);
+    assertEquals("", td.transformName);
+    assertNull(td.transformMeta);
+    assertEquals("", td.description);
+  }
+
+  @Test
+  void transformDefinition_fullConstructor_setsAllFields() {
+    TransformMeta tm = mock(TransformMeta.class);
+    TransformDefinition td = new TransformDefinition("t", "name", tm, "desc");
+    assertEquals("t", td.tag);
+    assertEquals("name", td.transformName);
+    assertEquals(tm, td.transformMeta);
+    assertEquals("desc", td.description);
+  }
+
+  @Test
+  void transformDefinition_copyConstructor_nullTransformMeta() {
+    TransformDefinition original = new TransformDefinition("tag", "step", 
null, "d");
+    TransformDefinition copy = new TransformDefinition(original);
+    assertNotSame(original, copy);
+    assertEquals(original.tag, copy.tag);
+    assertEquals(original.transformName, copy.transformName);
+    assertEquals(original.description, copy.description);
+    assertNull(copy.transformMeta);
+  }
+
+  @Test
+  void transformDefinition_clone_createsDistinctInstance() {
+    TransformDefinition td = new TransformDefinition("a", "b", null, "c");
+    TransformDefinition cloned = (TransformDefinition) td.clone();
+    assertNotSame(td, cloned);
+    assertEquals(td.tag, cloned.tag);
+    assertEquals(td.transformName, cloned.transformName);
+    assertEquals(td.description, cloned.description);
+  }
+
+  // ================================================================== 
InfoTransformDefinition
+
+  @Test
+  void infoTransformDefinition_defaultConstructor_emptyStrings() {
+    InfoTransformDefinition itd = new InfoTransformDefinition();
+    assertNotNull(itd);
+    assertNull(itd.transformMeta);
+  }
+
+  @Test
+  void infoTransformDefinition_copyConstructor_noTransformMeta() {
+    InfoTransformDefinition original = new InfoTransformDefinition();
+    original.setTag("info");
+    original.setTransformName("src");
+    original.setDescription("info desc");
+    original.transformMeta = null;
+
+    InfoTransformDefinition copy = new InfoTransformDefinition(original);
+    assertNotSame(original, copy);
+    assertEquals("info", copy.getTag());
+    assertEquals("src", copy.getTransformName());
+    assertEquals("info desc", copy.getDescription());
+    assertNull(copy.transformMeta);
+  }
+
+  @Test
+  void infoTransformDefinition_copyConstructor_withTransformMeta_clonesIt() {
+    InfoTransformDefinition original = new InfoTransformDefinition();
+    original.setTag("x");
+    TransformMeta tm = mock(TransformMeta.class);
+    original.transformMeta = tm;
+
+    InfoTransformDefinition copy = new InfoTransformDefinition(original);
+    // clone() should have been called — the field must not be the same 
reference
+    assertNotSame(tm, copy.transformMeta);
+  }
+
+  @Test
+  void infoTransformDefinition_clone_returnsDistinctInstance() {
+    InfoTransformDefinition itd = new InfoTransformDefinition();
+    itd.setTag("y");
+    InfoTransformDefinition cloned = (InfoTransformDefinition) itd.clone();
+    assertNotSame(itd, cloned);
+    assertEquals("y", cloned.getTag());
+  }
+
+  // ================================================================== 
TargetTransformDefinition
+
+  @Test
+  void targetTransformDefinition_defaultConstructor_emptyStrings() {
+    TargetTransformDefinition ttd = new TargetTransformDefinition();
+    assertNotNull(ttd);
+    assertNull(ttd.transformMeta);
+  }
+
+  @Test
+  void targetTransformDefinition_copyConstructor_noTransformMeta() {
+    TargetTransformDefinition original = new TargetTransformDefinition();
+    original.tag = "tgt";
+    original.transformName = "dest";
+    original.description = "tgt desc";
+    original.transformMeta = null;
+
+    TargetTransformDefinition copy = new TargetTransformDefinition(original);
+    assertNotSame(original, copy);
+    assertEquals("tgt", copy.tag);
+    assertEquals("dest", copy.transformName);
+    assertEquals("tgt desc", copy.description);
+    assertNull(copy.transformMeta);
+  }
+
+  @Test
+  void targetTransformDefinition_copyConstructor_withTransformMeta_clonesIt() {
+    TargetTransformDefinition original = new TargetTransformDefinition();
+    original.tag = "z";
+    TransformMeta tm = mock(TransformMeta.class);
+    original.transformMeta = tm;
+
+    TargetTransformDefinition copy = new TargetTransformDefinition(original);
+    assertNotSame(tm, copy.transformMeta);
+  }
+
+  @Test
+  void targetTransformDefinition_clone_returnsDistinctInstance() {
+    TargetTransformDefinition ttd = new TargetTransformDefinition();
+    ttd.tag = "alpha";
+    TargetTransformDefinition cloned = (TargetTransformDefinition) ttd.clone();
+    assertNotSame(ttd, cloned);
+    assertEquals("alpha", cloned.tag);
+  }
+
+  // ================================================================== 
UsageParameter
+
+  @Test
+  void usageParameter_defaultConstructor_allNull() {
+    UsageParameter up = new UsageParameter();
+    assertNull(up.getTag());
+    assertNull(up.getValue());
+    assertNull(up.getDescription());
+  }
+
+  @Test
+  void usageParameter_copyConstructor_copiesAllFields() {
+    UsageParameter original = new UsageParameter();
+    original.setTag("param1");
+    original.setValue("val1");
+    original.setDescription("a parameter");
+
+    UsageParameter copy = new UsageParameter(original);
+    assertNotSame(original, copy);
+    assertEquals("param1", copy.getTag());
+    assertEquals("val1", copy.getValue());
+    assertEquals("a parameter", copy.getDescription());
+  }
+
+  @Test
+  void usageParameter_clone_returnsDistinctInstance() {
+    UsageParameter up = new UsageParameter();
+    up.setTag("p");
+    up.setValue("v");
+    UsageParameter cloned = up.clone();
+    assertNotSame(up, cloned);
+    assertEquals("p", cloned.getTag());
+    assertEquals("v", cloned.getValue());
+  }
+
+  @Test
+  void usageParameter_setters_updateState() {
+    UsageParameter up = new UsageParameter();
+    up.setTag("t");
+    up.setValue("42");
+    up.setDescription("desc");
+    assertEquals("t", up.getTag());
+    assertEquals("42", up.getValue());
+    assertEquals("desc", up.getDescription());
+  }
+}
diff --git 
a/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/userdefinedjavaclass/UserDefinedJavaClassCodeSnippetsTest.java
 
b/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/userdefinedjavaclass/UserDefinedJavaClassCodeSnippetsTest.java
new file mode 100644
index 0000000000..5a0aeb2349
--- /dev/null
+++ 
b/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/userdefinedjavaclass/UserDefinedJavaClassCodeSnippetsTest.java
@@ -0,0 +1,86 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.hop.pipeline.transforms.userdefinedjavaclass;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import org.apache.hop.core.logging.HopLogStore;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+class UserDefinedJavaClassCodeSnippetsTest {
+
+  @BeforeAll
+  static void initLogStore() {
+    HopLogStore.init();
+  }
+
+  @Test
+  void getSnippetsHelper_returnsNonNull() throws Exception {
+    assertNotNull(UserDefinedJavaClassCodeSnippets.getSnippetsHelper());
+  }
+
+  @Test
+  void getSnippetsHelper_singleton_returnsSameInstance() throws Exception {
+    UserDefinedJavaClassCodeSnippets first = 
UserDefinedJavaClassCodeSnippets.getSnippetsHelper();
+    UserDefinedJavaClassCodeSnippets second = 
UserDefinedJavaClassCodeSnippets.getSnippetsHelper();
+    assertEquals(first, second);
+  }
+
+  @Test
+  void getSnippets_isNotEmpty() throws Exception {
+    
assertFalse(UserDefinedJavaClassCodeSnippets.getSnippetsHelper().getSnippets().isEmpty());
+  }
+
+  @Test
+  void getDefaultCode_isNotEmpty() throws Exception {
+    String code = 
UserDefinedJavaClassCodeSnippets.getSnippetsHelper().getDefaultCode();
+    assertNotNull(code);
+    assertFalse(code.isEmpty());
+  }
+
+  @Test
+  void getCode_knownSnippet_returnsNonEmpty() throws Exception {
+    UserDefinedJavaClassCodeSnippets helper = 
UserDefinedJavaClassCodeSnippets.getSnippetsHelper();
+    // "Implement processRow" is the canonical default snippet
+    String code = helper.getCode("Implement processRow");
+    assertNotNull(code);
+    assertFalse(code.isEmpty(), "Expected code for 'Implement processRow' but 
got empty");
+  }
+
+  @Test
+  void getCode_unknownSnippet_returnsEmpty() throws Exception {
+    String code = 
UserDefinedJavaClassCodeSnippets.getSnippetsHelper().getCode("NONEXISTENT");
+    assertEquals("", code);
+  }
+
+  @Test
+  void getSample_unknownSnippet_returnsEmpty() throws Exception {
+    String sample = 
UserDefinedJavaClassCodeSnippets.getSnippetsHelper().getSample("NONEXISTENT");
+    assertEquals("", sample);
+  }
+
+  @Test
+  void getSnippets_isUnmodifiable() throws Exception {
+    assertThrows(
+        UnsupportedOperationException.class,
+        () -> 
UserDefinedJavaClassCodeSnippets.getSnippetsHelper().getSnippets().clear());
+  }
+}
diff --git 
a/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/userdefinedjavaclass/UserDefinedJavaClassDefTest.java
 
b/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/userdefinedjavaclass/UserDefinedJavaClassDefTest.java
new file mode 100644
index 0000000000..25b3a26d0c
--- /dev/null
+++ 
b/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/userdefinedjavaclass/UserDefinedJavaClassDefTest.java
@@ -0,0 +1,153 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.hop.pipeline.transforms.userdefinedjavaclass;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import 
org.apache.hop.pipeline.transforms.userdefinedjavaclass.UserDefinedJavaClassDef.ClassType;
+import org.junit.jupiter.api.Test;
+
+class UserDefinedJavaClassDefTest {
+
+  private static UserDefinedJavaClassDef normal(String name, String src) {
+    return new UserDefinedJavaClassDef(ClassType.NORMAL_CLASS, name, src);
+  }
+
+  private static UserDefinedJavaClassDef transform(String name, String src) {
+    return new UserDefinedJavaClassDef(ClassType.TRANSFORM_CLASS, name, src);
+  }
+
+  // ------------------------------------------------------------------ 
constructors
+
+  @Test
+  void defaultConstructor_fieldsAreNull() {
+    UserDefinedJavaClassDef def = new UserDefinedJavaClassDef();
+    assertNull(def.getClassType());
+    assertNull(def.getClassName());
+    assertNull(def.getSource());
+  }
+
+  @Test
+  void fullConstructor_setsAllFields() {
+    UserDefinedJavaClassDef def = normal("Foo", "int x = 1;");
+    assertEquals(ClassType.NORMAL_CLASS, def.getClassType());
+    assertEquals("Foo", def.getClassName());
+    assertEquals("int x = 1;", def.getSource());
+  }
+
+  @Test
+  void copyConstructor_copiesAllFields() {
+    UserDefinedJavaClassDef original = transform("Bar", "void run(){}");
+    UserDefinedJavaClassDef copy = new UserDefinedJavaClassDef(original);
+    assertNotSame(original, copy);
+    assertEquals(original.getClassType(), copy.getClassType());
+    assertEquals(original.getClassName(), copy.getClassName());
+    assertEquals(original.getSource(), copy.getSource());
+  }
+
+  // ------------------------------------------------------------------ clone
+
+  @Test
+  void clone_returnsDistinctEqualInstance() throws CloneNotSupportedException {
+    UserDefinedJavaClassDef def = normal("Cloneable", "body");
+    UserDefinedJavaClassDef cloned = (UserDefinedJavaClassDef) def.clone();
+    assertNotSame(def, cloned);
+    assertEquals(def.getClassType(), cloned.getClassType());
+    assertEquals(def.getClassName(), cloned.getClassName());
+    assertEquals(def.getSource(), cloned.getSource());
+  }
+
+  // ------------------------------------------------------------------ 
isTransformClass
+
+  @Test
+  void isTransformClass_normalClass_returnsFalse() {
+    assertFalse(normal("X", "").isTransformClass());
+  }
+
+  @Test
+  void isTransformClass_transformClass_returnsTrue() {
+    assertTrue(transform("X", "").isTransformClass());
+  }
+
+  // ------------------------------------------------------------------ 
getChecksum
+
+  @Test
+  void getChecksum_stableForSameContent() throws Exception {
+    UserDefinedJavaClassDef a = normal("Foo", "int x = 1;");
+    UserDefinedJavaClassDef b = normal("Foo", "int x = 1;");
+    assertEquals(a.getChecksum(), b.getChecksum());
+  }
+
+  @Test
+  void getChecksum_differsForDifferentSource() throws Exception {
+    UserDefinedJavaClassDef a = normal("Foo", "int x = 1;");
+    UserDefinedJavaClassDef b = normal("Foo", "int y = 2;");
+    assertNotEquals(a.getChecksum(), b.getChecksum());
+  }
+
+  @Test
+  void getChecksum_differsForDifferentClassName() throws Exception {
+    UserDefinedJavaClassDef a = normal("Alpha", "int x = 1;");
+    UserDefinedJavaClassDef b = normal("Beta", "int x = 1;");
+    assertNotEquals(a.getChecksum(), b.getChecksum());
+  }
+
+  @Test
+  void getChecksum_isHexString() throws Exception {
+    String checksum = normal("Foo", "src").getChecksum();
+    assertTrue(checksum.matches("[0-9a-f]+"), "Expected lowercase hex but got: 
" + checksum);
+  }
+
+  // ------------------------------------------------------------------ 
getTransformedSource
+
+  @Test
+  void getTransformedSource_containsOriginalSource() {
+    UserDefinedJavaClassDef def = transform("MyClass", "public void run(){}");
+    assertTrue(def.getTransformedSource().contains("public void run(){}"));
+  }
+
+  @Test
+  void getTransformedSource_containsClassName() {
+    UserDefinedJavaClassDef def = transform("Processor", "");
+    String transformed = def.getTransformedSource();
+    assertTrue(transformed.contains("Processor"));
+  }
+
+  @Test
+  void getTransformedSource_containsSuperConstructorCall() {
+    UserDefinedJavaClassDef def = transform("MyTransform", "");
+    assertTrue(def.getTransformedSource().contains("super(parent,meta,data)"));
+  }
+
+  // ------------------------------------------------------------------ 
setters (Lombok)
+
+  @Test
+  void setters_updateFields() {
+    UserDefinedJavaClassDef def = new UserDefinedJavaClassDef();
+    def.setClassType(ClassType.TRANSFORM_CLASS);
+    def.setClassName("Updated");
+    def.setSource("new source");
+    assertEquals(ClassType.TRANSFORM_CLASS, def.getClassType());
+    assertEquals("Updated", def.getClassName());
+    assertEquals("new source", def.getSource());
+  }
+}
diff --git 
a/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/userdefinedjavaclass/UserDefinedJavaClassMetaTest.java
 
b/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/userdefinedjavaclass/UserDefinedJavaClassMetaTest.java
index 55a11f04fd..9a307d333d 100644
--- 
a/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/userdefinedjavaclass/UserDefinedJavaClassMetaTest.java
+++ 
b/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/userdefinedjavaclass/UserDefinedJavaClassMetaTest.java
@@ -20,23 +20,35 @@ package 
org.apache.hop.pipeline.transforms.userdefinedjavaclass;
 import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
 
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import org.apache.hop.core.ICheckResult;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.exception.HopRuntimeException;
+import org.apache.hop.core.exception.HopTransformException;
 import org.apache.hop.core.plugins.PluginRegistry;
+import org.apache.hop.core.row.IRowMeta;
 import org.apache.hop.core.row.IValueMeta;
+import org.apache.hop.core.row.RowMeta;
 import org.apache.hop.core.row.value.ValueMetaDate;
 import org.apache.hop.core.row.value.ValueMetaInteger;
 import org.apache.hop.core.row.value.ValueMetaNumber;
 import org.apache.hop.core.row.value.ValueMetaPlugin;
 import org.apache.hop.core.row.value.ValueMetaPluginType;
 import org.apache.hop.core.row.value.ValueMetaString;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.pipeline.PipelineMeta;
 import org.apache.hop.pipeline.transform.TransformMeta;
 import org.apache.hop.pipeline.transform.TransformSerializationTestUtil;
+import org.apache.hop.pipeline.transforms.janino.JaninoMeta;
+import org.codehaus.commons.compiler.CompileException;
 import org.junit.jupiter.api.Test;
 import org.mockito.Mockito;
 
@@ -195,6 +207,8 @@ class UserDefinedJavaClassMetaTest {
 
   private void validate(UserDefinedJavaClassMeta meta) {
     assertFalse(meta.isClearingResultFields());
+    assertEquals(17, meta.getJavaTargetVersion());
+    assertEquals(17, meta.getEffectiveJavaTargetVersion());
 
     // Definitions
     assertEquals(2, meta.getDefinitions().size());
@@ -263,4 +277,363 @@ class UserDefinedJavaClassMetaTest {
     assertEquals("parameterValue2", p.getValue());
     assertEquals("parameterDescription2", p.getDescription());
   }
+
+  // ------------------------------------------------------------------
+  // getEffectiveJavaTargetVersion
+
+  @Test
+  void effectiveVersion_default_returnsDefault() {
+    UserDefinedJavaClassMeta meta = new UserDefinedJavaClassMeta();
+    assertEquals(JaninoMeta.JAVA_TARGET_VERSION_DEFAULT, 
meta.getEffectiveJavaTargetVersion());
+  }
+
+  @Test
+  void effectiveVersion_validValues_returnAsIs() {
+    UserDefinedJavaClassMeta meta = new UserDefinedJavaClassMeta();
+    meta.setJavaTargetVersion(8);
+    assertEquals(8, meta.getEffectiveJavaTargetVersion());
+    meta.setJavaTargetVersion(11);
+    assertEquals(11, meta.getEffectiveJavaTargetVersion());
+    meta.setJavaTargetVersion(JaninoMeta.JAVA_TARGET_VERSION_MIN);
+    assertEquals(JaninoMeta.JAVA_TARGET_VERSION_MIN, 
meta.getEffectiveJavaTargetVersion());
+    meta.setJavaTargetVersion(JaninoMeta.JAVA_TARGET_VERSION_MAX);
+    assertEquals(JaninoMeta.JAVA_TARGET_VERSION_MAX, 
meta.getEffectiveJavaTargetVersion());
+  }
+
+  @Test
+  void effectiveVersion_tooLow_returnsDefault() {
+    UserDefinedJavaClassMeta meta = new UserDefinedJavaClassMeta();
+    meta.setJavaTargetVersion(0);
+    assertEquals(JaninoMeta.JAVA_TARGET_VERSION_DEFAULT, 
meta.getEffectiveJavaTargetVersion());
+    meta.setJavaTargetVersion(-5);
+    assertEquals(JaninoMeta.JAVA_TARGET_VERSION_DEFAULT, 
meta.getEffectiveJavaTargetVersion());
+  }
+
+  @Test
+  void effectiveVersion_tooHigh_returnsDefault() {
+    UserDefinedJavaClassMeta meta = new UserDefinedJavaClassMeta();
+    meta.setJavaTargetVersion(99);
+    assertEquals(JaninoMeta.JAVA_TARGET_VERSION_DEFAULT, 
meta.getEffectiveJavaTargetVersion());
+  }
+
+  // ------------------------------------------------------------------ 
setJavaTargetVersion marks
+  // hasChanged
+
+  @Test
+  void setJavaTargetVersion_differentValue_marksHasChanged() throws 
HopException {
+    String code =
+        "public boolean processRow() throws HopException { setOutputDone(); 
return false; }\n";
+    UserDefinedJavaClassMeta meta = new UserDefinedJavaClassMeta();
+    // Cook once so hasChanged becomes false
+    UserDefinedJavaClassDef def =
+        new UserDefinedJavaClassDef(
+            UserDefinedJavaClassDef.ClassType.TRANSFORM_CLASS, "Proc", code);
+    meta.replaceDefinitions(new ArrayList<>(Collections.singletonList(def)));
+    meta.cookClasses();
+
+    // Now change the version — should set hasChanged
+    meta.setJavaTargetVersion(8);
+    assertEquals(8, meta.getJavaTargetVersion());
+  }
+
+  @Test
+  void setJavaTargetVersion_sameValue_doesNotAlterValue() {
+    UserDefinedJavaClassMeta meta = new UserDefinedJavaClassMeta();
+    int initial = meta.getJavaTargetVersion();
+    meta.setJavaTargetVersion(initial); // same value
+    assertEquals(initial, meta.getJavaTargetVersion());
+  }
+
+  // ------------------------------------------------------------------ clone 
copies version
+
+  @Test
+  void clone_copiesJavaTargetVersion() {
+    UserDefinedJavaClassMeta original = new UserDefinedJavaClassMeta();
+    original.setJavaTargetVersion(17);
+    UserDefinedJavaClassMeta copy = original.clone();
+
+    assertNotSame(original, copy);
+    assertEquals(17, copy.getJavaTargetVersion());
+    assertEquals(17, copy.getEffectiveJavaTargetVersion());
+  }
+
+  // ------------------------------------------------------------------ 
cookClass: different target
+  // versions
+
+  @Test
+  void cookClass_target8_staticInterfaceMethod_succeeds() throws Exception {
+    String code =
+        "public int cmp() {\n"
+            + "  return 
java.util.Comparator.naturalOrder().compare(Integer.valueOf(3), 
Integer.valueOf(5));\n"
+            + "}\n";
+    UserDefinedJavaClassMeta meta = new UserDefinedJavaClassMeta();
+    meta.setJavaTargetVersion(8);
+    UserDefinedJavaClassDef def =
+        new UserDefinedJavaClassDef(
+            UserDefinedJavaClassDef.ClassType.NORMAL_CLASS, "CmpClass", code);
+
+    Class<?> cooked = meta.cookClass(def, null);
+    assertNotSame(null, cooked);
+    assertEquals("CmpClass", cooked.getSimpleName());
+  }
+
+  @Test
+  void cookClass_target17_staticInterfaceMethod_succeeds() throws Exception {
+    String code =
+        "public int cmp() {\n"
+            + "  return 
java.util.Comparator.naturalOrder().compare(Integer.valueOf(3), 
Integer.valueOf(5));\n"
+            + "}\n";
+    UserDefinedJavaClassMeta meta = new UserDefinedJavaClassMeta();
+    meta.setJavaTargetVersion(17);
+    UserDefinedJavaClassDef def =
+        new UserDefinedJavaClassDef(
+            UserDefinedJavaClassDef.ClassType.NORMAL_CLASS, "CmpClass17", 
code);
+
+    Class<?> cooked = meta.cookClass(def, null);
+    assertNotSame(null, cooked);
+  }
+
+  @Test
+  void cookClass_target6_staticInterfaceMethod_throwsCompileException() {
+    String code =
+        "public int cmp() {\n"
+            + "  return 
java.util.Comparator.naturalOrder().compare(Integer.valueOf(3), 
Integer.valueOf(5));\n"
+            + "}\n";
+    UserDefinedJavaClassMeta meta = new UserDefinedJavaClassMeta();
+    meta.setJavaTargetVersion(6);
+    UserDefinedJavaClassDef def =
+        new UserDefinedJavaClassDef(
+            UserDefinedJavaClassDef.ClassType.NORMAL_CLASS, "CmpClass6", code);
+
+    assertThrows(CompileException.class, () -> meta.cookClass(def, null));
+  }
+
+  // ------------------------------------------------------------------ cache: 
different versions
+  // use separate entries
+
+  @Test
+  void cookClass_sameCodeDifferentTargets_returnsDifferentClasses() throws 
Exception {
+    String code = "public int val() { return 1; }\n";
+
+    UserDefinedJavaClassMeta meta8 = new UserDefinedJavaClassMeta();
+    meta8.setJavaTargetVersion(8);
+    UserDefinedJavaClassDef def8 =
+        new UserDefinedJavaClassDef(
+            UserDefinedJavaClassDef.ClassType.NORMAL_CLASS, "ValClass", code);
+
+    UserDefinedJavaClassMeta meta17 = new UserDefinedJavaClassMeta();
+    meta17.setJavaTargetVersion(17);
+    UserDefinedJavaClassDef def17 =
+        new UserDefinedJavaClassDef(
+            UserDefinedJavaClassDef.ClassType.NORMAL_CLASS, "ValClass", code);
+
+    Class<?> cooked8 = meta8.cookClass(def8, null);
+    Class<?> cooked17 = meta17.cookClass(def17, null);
+    // Both succeed; they must be non-null
+    assertNotSame(null, cooked8);
+    assertNotSame(null, cooked17);
+  }
+
+  // ------------------------------------------------------------------ check()
+
+  @Test
+  void check_withInputTransforms_addsOk() {
+    UserDefinedJavaClassMeta meta = new UserDefinedJavaClassMeta();
+    List<ICheckResult> remarks = new ArrayList<>();
+    meta.check(
+        remarks,
+        mock(PipelineMeta.class),
+        mock(TransformMeta.class),
+        new RowMeta(),
+        new String[] {"in"},
+        new String[0],
+        mock(IRowMeta.class),
+        null,
+        mock(IHopMetadataProvider.class));
+
+    assertTrue(remarks.stream().anyMatch(r -> r.getType() == 
ICheckResult.TYPE_RESULT_OK));
+  }
+
+  @Test
+  void check_noInputTransforms_addsError() {
+    UserDefinedJavaClassMeta meta = new UserDefinedJavaClassMeta();
+    List<ICheckResult> remarks = new ArrayList<>();
+    meta.check(
+        remarks,
+        mock(PipelineMeta.class),
+        mock(TransformMeta.class),
+        new RowMeta(),
+        new String[0],
+        new String[0],
+        mock(IRowMeta.class),
+        null,
+        mock(IHopMetadataProvider.class));
+
+    assertTrue(remarks.stream().anyMatch(r -> r.getType() == 
ICheckResult.TYPE_RESULT_ERROR));
+  }
+
+  // ------------------------------------------------------------------ 
supportsErrorHandling /
+  // excludeFromRowLayoutVerification
+
+  @Test
+  void supportsErrorHandling_returnsTrue() {
+    assertTrue(new UserDefinedJavaClassMeta().supportsErrorHandling());
+  }
+
+  @Test
+  void excludeFromRowLayoutVerification_returnsTrue() {
+    assertTrue(new 
UserDefinedJavaClassMeta().excludeFromRowLayoutVerification());
+  }
+
+  // ------------------------------------------------------------------ 
FieldInfo
+
+  @Test
+  void fieldInfo_constructorAndGetters() {
+    UserDefinedJavaClassMeta.FieldInfo f =
+        new UserDefinedJavaClassMeta.FieldInfo("myField", 
IValueMeta.TYPE_INTEGER, 9, 2);
+    assertEquals("myField", f.getName());
+    assertEquals(IValueMeta.TYPE_INTEGER, f.getType());
+    assertEquals(9, f.getLength());
+    assertEquals(2, f.getPrecision());
+  }
+
+  @Test
+  void fieldInfo_copyConstructor_copiesAllFields() {
+    UserDefinedJavaClassMeta.FieldInfo original =
+        new UserDefinedJavaClassMeta.FieldInfo("x", IValueMeta.TYPE_STRING, 
50, -1);
+    UserDefinedJavaClassMeta.FieldInfo copy = new 
UserDefinedJavaClassMeta.FieldInfo(original);
+    assertNotSame(original, copy);
+    assertEquals("x", copy.getName());
+    assertEquals(IValueMeta.TYPE_STRING, copy.getType());
+    assertEquals(50, copy.getLength());
+    assertEquals(-1, copy.getPrecision());
+  }
+
+  @Test
+  void fieldInfo_setters() {
+    UserDefinedJavaClassMeta.FieldInfo f = new 
UserDefinedJavaClassMeta.FieldInfo();
+    f.setName("n");
+    f.setType(IValueMeta.TYPE_NUMBER);
+    f.setLength(10);
+    f.setPrecision(3);
+    assertEquals("n", f.getName());
+    assertEquals(IValueMeta.TYPE_NUMBER, f.getType());
+    assertEquals(10, f.getLength());
+    assertEquals(3, f.getPrecision());
+  }
+
+  // ------------------------------------------------------------------ 
replaceFields / setFieldInfo
+
+  @Test
+  void replaceFields_updatesFieldsList() {
+    UserDefinedJavaClassMeta meta = new UserDefinedJavaClassMeta();
+    List<UserDefinedJavaClassMeta.FieldInfo> newFields = new ArrayList<>();
+    newFields.add(new UserDefinedJavaClassMeta.FieldInfo("a", 
IValueMeta.TYPE_STRING, 10, -1));
+    meta.replaceFields(newFields);
+    assertEquals(1, meta.getFields().size());
+    assertEquals("a", meta.getFields().get(0).getName());
+  }
+
+  @Test
+  void setFieldInfo_delegatesToReplaceFields() {
+    UserDefinedJavaClassMeta meta = new UserDefinedJavaClassMeta();
+    List<UserDefinedJavaClassMeta.FieldInfo> fields = new ArrayList<>();
+    fields.add(new UserDefinedJavaClassMeta.FieldInfo("b", 
IValueMeta.TYPE_INTEGER, 9, 0));
+    meta.setFieldInfo(fields);
+    assertEquals(1, meta.getFields().size());
+    assertEquals("b", meta.getFields().get(0).getName());
+  }
+
+  // ------------------------------------------------------------------ 
replaceDefinitions
+
+  @Test
+  void replaceDefinitions_ordersNormalBeforeTransformClasses() {
+    UserDefinedJavaClassMeta meta = new UserDefinedJavaClassMeta();
+    List<UserDefinedJavaClassDef> defs = new ArrayList<>();
+    defs.add(
+        new UserDefinedJavaClassDef(
+            UserDefinedJavaClassDef.ClassType.TRANSFORM_CLASS,
+            "Proc",
+            "public boolean processRow() { return true; }\n"));
+    defs.add(
+        new UserDefinedJavaClassDef(
+            UserDefinedJavaClassDef.ClassType.NORMAL_CLASS,
+            "Helper",
+            "public int x() { return 1; }\n"));
+    meta.replaceDefinitions(defs);
+
+    // Normal classes come first
+    assertEquals("Helper", meta.getDefinitions().get(0).getClassName());
+    assertEquals("Proc", meta.getDefinitions().get(1).getClassName());
+  }
+
+  // ------------------------------------------------------------------
+  // searchInfoAndTargetTransforms
+
+  @Test
+  void searchInfoAndTargetTransforms_resolvesMatchingTransforms() {
+    UserDefinedJavaClassMeta meta = new UserDefinedJavaClassMeta();
+
+    InfoTransformDefinition infoDef = new InfoTransformDefinition();
+    infoDef.setTag("lookup");
+    infoDef.setTransformName("LookupStep");
+    meta.getInfoTransformDefinitions().add(infoDef);
+
+    TargetTransformDefinition targetDef = new TargetTransformDefinition();
+    targetDef.tag = "out";
+    targetDef.transformName = "OutputStep";
+    meta.getTargetTransformDefinitions().add(targetDef);
+
+    TransformMeta lookupMeta = Mockito.mock(TransformMeta.class);
+    Mockito.when(lookupMeta.getName()).thenReturn("LookupStep");
+    TransformMeta outputMeta = Mockito.mock(TransformMeta.class);
+    Mockito.when(outputMeta.getName()).thenReturn("OutputStep");
+
+    meta.searchInfoAndTargetTransforms(List.of(lookupMeta, outputMeta));
+
+    assertEquals(lookupMeta, infoDef.transformMeta);
+    assertEquals(outputMeta, targetDef.transformMeta);
+  }
+
+  @Test
+  void searchInfoAndTargetTransforms_noMatchSetsNull() {
+    UserDefinedJavaClassMeta meta = new UserDefinedJavaClassMeta();
+
+    InfoTransformDefinition infoDef = new InfoTransformDefinition();
+    infoDef.setTag("lookup");
+    infoDef.setTransformName("NonExistent");
+    meta.getInfoTransformDefinitions().add(infoDef);
+
+    TransformMeta otherMeta = Mockito.mock(TransformMeta.class);
+    Mockito.when(otherMeta.getName()).thenReturn("SomeOtherStep");
+
+    meta.searchInfoAndTargetTransforms(List.of(otherMeta));
+
+    assertNull(infoDef.transformMeta);
+  }
+
+  // ------------------------------------------------------------------ 
getFields: cook errors
+
+  @Test
+  void getFields_withCookErrors_throwsHopTransformException() throws Exception 
{
+    UserDefinedJavaClassMeta meta = new UserDefinedJavaClassMeta();
+    UserDefinedJavaClassDef badDef = 
Mockito.mock(UserDefinedJavaClassDef.class);
+    Mockito.when(badDef.isTransformClass()).thenReturn(false);
+    Mockito.when(badDef.getSource()).thenReturn("THIS IS NOT JAVA !!!");
+    Mockito.when(badDef.getClassName()).thenReturn("Bad");
+    // getChecksum() throws HopTransformException – use doReturn to avoid the 
checked-exception
+    // surfacing in the when() call itself
+    Mockito.doReturn("badchecksum-unique-" + 
System.nanoTime()).when(badDef).getChecksum();
+
+    UserDefinedJavaClassMeta spy = Mockito.spy(meta);
+    
Mockito.when(spy.getDefinitions()).thenReturn(Collections.singletonList(badDef));
+
+    TransformMeta transformMeta = Mockito.mock(TransformMeta.class);
+    Mockito.when(transformMeta.getName()).thenReturn("UDJC");
+    spy.setParentTransformMeta(transformMeta);
+
+    assertThrows(
+        HopTransformException.class,
+        () -> spy.getFields(new RowMeta(), "step", null, null, null, null));
+  }
 }
diff --git 
a/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/userdefinedjavaclass/UserDefinedJavaClassTest.java
 
b/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/userdefinedjavaclass/UserDefinedJavaClassTest.java
new file mode 100644
index 0000000000..396457f25a
--- /dev/null
+++ 
b/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/userdefinedjavaclass/UserDefinedJavaClassTest.java
@@ -0,0 +1,188 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.hop.pipeline.transforms.userdefinedjavaclass;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+import java.util.Collections;
+import org.apache.hop.core.logging.HopLogStore;
+import org.apache.hop.core.logging.ILoggingObject;
+import org.apache.hop.core.plugins.PluginRegistry;
+import org.apache.hop.core.row.value.ValueMetaInteger;
+import org.apache.hop.core.row.value.ValueMetaPlugin;
+import org.apache.hop.core.row.value.ValueMetaPluginType;
+import org.apache.hop.core.row.value.ValueMetaString;
+import org.apache.hop.pipeline.transforms.mock.TransformMockHelper;
+import 
org.apache.hop.pipeline.transforms.userdefinedjavaclass.UserDefinedJavaClassDef.ClassType;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Unit tests for {@link UserDefinedJavaClass} lifecycle: constructor, {@code 
processRow()}, and
+ * {@code init()}.
+ */
+class UserDefinedJavaClassTest {
+
+  private TransformMockHelper<UserDefinedJavaClassMeta, 
UserDefinedJavaClassData> helper;
+
+  @BeforeAll
+  static void initPlugins() throws Exception {
+    HopLogStore.init();
+    PluginRegistry registry = PluginRegistry.getInstance();
+    registry.registerPluginClass(
+        ValueMetaString.class.getName(), ValueMetaPluginType.class, 
ValueMetaPlugin.class);
+    registry.registerPluginClass(
+        ValueMetaInteger.class.getName(), ValueMetaPluginType.class, 
ValueMetaPlugin.class);
+  }
+
+  @BeforeEach
+  void setUp() {
+    helper =
+        new TransformMockHelper<>(
+            "UDJC TEST", UserDefinedJavaClassMeta.class, 
UserDefinedJavaClassData.class);
+    when(helper.logChannelFactory.create(any(), any(ILoggingObject.class)))
+        .thenReturn(helper.iLogChannel);
+    when(helper.pipeline.isRunning()).thenReturn(true);
+  }
+
+  @AfterEach
+  void tearDown() {
+    helper.cleanUp();
+  }
+
+  // ------------------------------------------------------------------ helpers
+
+  /**
+   * Builds a {@link UserDefinedJavaClass} backed by the given meta. Uses 
{@code copyNr=0} so the
+   * constructor calls {@code cookClasses()}.
+   */
+  private UserDefinedJavaClass build(UserDefinedJavaClassMeta meta) {
+    return new UserDefinedJavaClass(
+        helper.transformMeta,
+        meta,
+        new UserDefinedJavaClassData(),
+        0,
+        helper.pipelineMeta,
+        helper.pipeline);
+  }
+
+  // ------------------------------------------------------------------ 
processRow: null child
+
+  @Test
+  void processRow_withNullChild_returnsFalse() throws Exception {
+    // Meta with no definitions → no transform class → child stays null
+    UserDefinedJavaClassMeta meta = new UserDefinedJavaClassMeta();
+    UserDefinedJavaClass transform = build(meta);
+
+    assertNull(transform.getChild());
+    assertFalse(transform.processRow());
+  }
+
+  // ------------------------------------------------------------------ init: 
no cooked class
+
+  @Test
+  void init_noTransformClassDefined_returnsFalse() {
+    // Meta with no definitions → cookedTransformClass remains null
+    UserDefinedJavaClassMeta meta = new UserDefinedJavaClassMeta();
+    UserDefinedJavaClass transform = build(meta);
+
+    assertFalse(transform.init());
+  }
+
+  // ------------------------------------------------------------------ init: 
cook errors
+
+  @Test
+  void init_withCookErrors_returnsFalse() {
+    UserDefinedJavaClassMeta meta = new UserDefinedJavaClassMeta();
+    // Duplicate method → compilation error
+    String badSource =
+        "public boolean processRow() { return true; }\n"
+            + "public boolean processRow() { return true; }\n";
+    meta.getDefinitions()
+        .add(new UserDefinedJavaClassDef(ClassType.NORMAL_CLASS, "BadClass", 
badSource));
+
+    UserDefinedJavaClass transform = build(meta);
+
+    assertFalse(meta.getCookErrors().isEmpty());
+    assertFalse(transform.init());
+  }
+
+  // ------------------------------------------------------------------ 
constructor: copyNr != 0
+  // skips explicit cook
+
+  @Test
+  void constructor_copyNrNonZero_constructsWithoutThrowingAndNoCookErrors() {
+    // copyNr=1: explicit cookClasses() call in the constructor body is 
skipped;
+    // cooking happens lazily inside newChildInstance() via checkClassCooked().
+    // For a clean meta with no definitions, no cook errors occur.
+    UserDefinedJavaClassMeta meta = new UserDefinedJavaClassMeta();
+
+    new UserDefinedJavaClass(
+        helper.transformMeta,
+        meta,
+        new UserDefinedJavaClassData(),
+        1,
+        helper.pipelineMeta,
+        helper.pipeline);
+
+    assertTrue(meta.getCookErrors().isEmpty());
+  }
+
+  // ------------------------------------------------------------------ 
cookClasses: TRANSFORM_CLASS
+
+  @Test
+  void cookClasses_withValidTransformClass_setsCookedClass() throws Exception {
+    // Call cookClasses() directly to verify compilation independent of child 
instantiation
+    // (child instantiation fails in tests because 
pipelineMeta.getPrevTransformFields returns
+    // null).
+    String source =
+        "public boolean processRow() throws 
org.apache.hop.core.exception.HopException {\n"
+            + "  setOutputDone();\n"
+            + "  return false;\n"
+            + "}\n";
+
+    UserDefinedJavaClassMeta meta = new UserDefinedJavaClassMeta();
+    meta.getDefinitions()
+        .add(new UserDefinedJavaClassDef(ClassType.TRANSFORM_CLASS, 
"Processor", source));
+
+    meta.cookClasses();
+
+    assertTrue(meta.getCookErrors().isEmpty(), "Expected no cook errors");
+    assertFalse(meta.getCookedTransformClass() == null, "Expected 
cookedTransformClass to be set");
+  }
+
+  // ------------------------------------------------------------------ 
getDefinitions / hasChanged
+
+  @Test
+  void metaGetDefinitions_initiallyEmpty() {
+    UserDefinedJavaClassMeta meta = new UserDefinedJavaClassMeta();
+    assertTrue(meta.getDefinitions().isEmpty());
+  }
+
+  @Test
+  void metaReplaceDefinitions_setsHasChanged() {
+    UserDefinedJavaClassMeta meta = new UserDefinedJavaClassMeta();
+    meta.replaceDefinitions(Collections.emptyList());
+    assertTrue(meta.isHasChanged());
+  }
+}
diff --git 
a/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/util/JaninoCheckerUtilTest.java
 
b/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/util/JaninoCheckerUtilTest.java
new file mode 100644
index 0000000000..fff1da2d38
--- /dev/null
+++ 
b/plugins/transforms/janino/src/test/java/org/apache/hop/pipeline/transforms/util/JaninoCheckerUtilTest.java
@@ -0,0 +1,124 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.hop.pipeline.transforms.util;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import org.apache.hop.core.logging.HopLogStore;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+class JaninoCheckerUtilTest {
+
+  @BeforeAll
+  static void init() {
+    HopLogStore.init();
+  }
+
+  // ------------------------------------------------------------------ 
constructor
+
+  @Test
+  void constructor_noExclusionsFile_doesNotThrow() {
+    // The codeExclusions.xml file will not be found in test env → logged, no 
throw
+    assertDoesNotThrow(JaninoCheckerUtil::new);
+  }
+
+  @Test
+  void constructor_whenFileNotFound_matchesListIsEmpty() {
+    JaninoCheckerUtil util = new JaninoCheckerUtil();
+    // No exclusions file in test classpath → empty matches
+    assertTrue(util.matches.isEmpty());
+  }
+
+  // ------------------------------------------------------------------ 
checkCode: no exclusions
+
+  @Test
+  void checkCode_noExclusions_alwaysReturnsEmpty() {
+    JaninoCheckerUtil util = new JaninoCheckerUtil();
+    assertTrue(util.checkCode("System.exit(0);").isEmpty());
+    assertTrue(util.checkCode("Runtime.getRuntime().exec(\"cmd\")").isEmpty());
+    assertTrue(util.checkCode("").isEmpty());
+  }
+
+  // ------------------------------------------------------------------ 
checkCode: with exclusions
+
+  @Test
+  void checkCode_matchingExclusion_returnsIt() {
+    JaninoCheckerUtil util = new JaninoCheckerUtil();
+    util.matches.add("System.exit");
+
+    List<String> violations = util.checkCode("System.exit(0);");
+    assertEquals(1, violations.size());
+    assertEquals("System.exit", violations.get(0));
+  }
+
+  @Test
+  void checkCode_noMatchingExclusion_returnsEmpty() {
+    JaninoCheckerUtil util = new JaninoCheckerUtil();
+    util.matches.add("System.exit");
+
+    assertTrue(util.checkCode("Math.abs(-1)").isEmpty());
+  }
+
+  @Test
+  void checkCode_multipleExclusions_onlySomeMatch() {
+    JaninoCheckerUtil util = new JaninoCheckerUtil();
+    util.matches.add("System.exit");
+    util.matches.add("Runtime.exec");
+    util.matches.add("ProcessBuilder");
+
+    List<String> violations = util.checkCode("Runtime.exec(\"cmd\")");
+    assertEquals(1, violations.size());
+    assertEquals("Runtime.exec", violations.get(0));
+  }
+
+  @Test
+  void checkCode_multipleExclusionsAllMatch_returnsAll() {
+    JaninoCheckerUtil util = new JaninoCheckerUtil();
+    util.matches.add("System.exit");
+    util.matches.add("Runtime.exec");
+
+    List<String> violations = util.checkCode("System.exit(0); 
Runtime.exec(\"cmd\")");
+    assertEquals(2, violations.size());
+    assertTrue(violations.contains("System.exit"));
+    assertTrue(violations.contains("Runtime.exec"));
+  }
+
+  @Test
+  void checkCode_partialMatch_returnsMatch() {
+    JaninoCheckerUtil util = new JaninoCheckerUtil();
+    util.matches.add("exit");
+
+    // "exit" appears as a substring within "System.exit"
+    assertFalse(util.checkCode("System.exit(1)").isEmpty());
+  }
+
+  // ------------------------------------------------------------------ 
getJarPath
+
+  @Test
+  void getJarPath_returnsNonNullNonEmpty() {
+    JaninoCheckerUtil util = new JaninoCheckerUtil();
+    String path = util.getJarPath();
+    assertNotNull(path);
+    assertFalse(path.isBlank());
+  }
+}
diff --git a/plugins/transforms/janino/src/test/resources/java-filter.xml 
b/plugins/transforms/janino/src/test/resources/java-filter.xml
new file mode 100644
index 0000000000..a08dd68a84
--- /dev/null
+++ b/plugins/transforms/janino/src/test/resources/java-filter.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  ~ Licensed to the Apache Software Foundation (ASF) under one or more
+  ~ contributor license agreements.  See the NOTICE file distributed with
+  ~ this work for additional information regarding copyright ownership.
+  ~ The ASF licenses this file to You under the Apache License, Version 2.0
+  ~ (the "License"); you may not use this file except in compliance with
+  ~ the License.  You may obtain a copy of the License at
+  ~
+  ~       http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing, software
+  ~ distributed under the License is distributed on an "AS IS" BASIS,
+  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  ~ See the License for the specific language governing permissions and
+  ~ limitations under the License.
+  -->
+<transform>
+    <condition>amount > 0</condition>
+    <send_true_to>positiveRows</send_true_to>
+    <send_false_to>negativeRows</send_false_to>
+</transform>
diff --git 
a/plugins/transforms/janino/src/test/resources/user-defined-java-class.xml 
b/plugins/transforms/janino/src/test/resources/user-defined-java-class.xml
index 63fdbdb6bb..553e969706 100644
--- a/plugins/transforms/janino/src/test/resources/user-defined-java-class.xml
+++ b/plugins/transforms/janino/src/test/resources/user-defined-java-class.xml
@@ -50,6 +50,7 @@
         </field>
     </fields>
     <clear_result_fields>N</clear_result_fields>
+    <java_target_version>17</java_target_version>
     <info_transforms>
         <info_transform>
             <transform_tag>info1</transform_tag>


Reply via email to