You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@hop.apache.org by ha...@apache.org on 2026/04/17 21:18:22 UTC

(hop) branch main updated: switch case detach and tests, fixes #3969 (#7021)

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 6d8a6e2994 switch case detach and tests, fixes #3969 (#7021)
6d8a6e2994 is described below

commit 6d8a6e29940f30bc3f1faf50ce2187e777e24a51
Author: Hans Van Akelyen <ha...@gmail.com>
AuthorDate: Fri Apr 17 23:18:16 2026 +0200

    switch case detach and tests, fixes #3969 (#7021)
---
 .../transforms/switchcase/SwitchCaseMeta.java      |  44 +-
 .../switchcase/ContainsKeyToRowSetMapTest.java     | 204 +++++++++
 .../transforms/switchcase/KeyToRowSetMapTest.java  | 186 ++++++++
 .../switchcase/SwitchCaseMetaTopologyTest.java     | 500 +++++++++++++++++++++
 .../switchcase/SwitchCaseRoutingTest.java          | 452 +++++++++++++++++++
 .../hopgui/file/pipeline/HopGuiPipelineGraph.java  |  53 ++-
 .../delegates/HopGuiPipelineHopDelegate.java       |   3 +
 .../delegates/HopGuiPipelineTransformDelegate.java |   3 +
 8 files changed, 1437 insertions(+), 8 deletions(-)

diff --git a/plugins/transforms/switchcase/src/main/java/org/apache/hop/pipeline/transforms/switchcase/SwitchCaseMeta.java b/plugins/transforms/switchcase/src/main/java/org/apache/hop/pipeline/transforms/switchcase/SwitchCaseMeta.java
index 785fa6683e..a784dabc88 100644
--- a/plugins/transforms/switchcase/src/main/java/org/apache/hop/pipeline/transforms/switchcase/SwitchCaseMeta.java
+++ b/plugins/transforms/switchcase/src/main/java/org/apache/hop/pipeline/transforms/switchcase/SwitchCaseMeta.java
@@ -363,7 +363,49 @@ public class SwitchCaseMeta extends BaseTransformMeta<SwitchCase, SwitchCaseData
 
   @Override
   public void convertIOMetaToTransformNames() {
-    // TODO
+    List<IStream> targetStreams = getTransformIOMeta().getTargetStreams();
+    int index = 0;
+    for (SwitchCaseTarget target : caseTargets) {
+      if (index < targetStreams.size()) {
+        IStream stream = targetStreams.get(index);
+        target.setCaseTargetTransformName(stream.getTransformName());
+      }
+      index++;
+    }
+    // Last stream is the default target (if any)
+    if (StringUtils.isNotEmpty(defaultTargetTransformName) && index < targetStreams.size()) {
+      defaultTargetTransformName = targetStreams.get(index).getTransformName();
+    }
+  }
+
+  @Override
+  public boolean cleanAfterHopFromRemove(TransformMeta toTransform) {
+    if (toTransform == null) {
+      return false;
+    }
+    String toName = toTransform.getName();
+    boolean changed = false;
+
+    // Clear matching case targets
+    for (SwitchCaseTarget target : caseTargets) {
+      if (toName.equals(target.getCaseTargetTransformName())) {
+        target.setCaseTargetTransformName(null);
+        changed = true;
+      }
+    }
+
+    // Clear default target if it matches
+    if (toName.equals(defaultTargetTransformName)) {
+      defaultTargetTransformName = null;
+      changed = true;
+    }
+
+    if (changed) {
+      // Reset the cached IO meta so it is rebuilt without the stale entries on next access
+      resetTransformIoMeta();
+    }
+
+    return changed;
   }
 
   private static final IStream newDefaultStream =
diff --git a/plugins/transforms/switchcase/src/test/java/org/apache/hop/pipeline/transforms/switchcase/ContainsKeyToRowSetMapTest.java b/plugins/transforms/switchcase/src/test/java/org/apache/hop/pipeline/transforms/switchcase/ContainsKeyToRowSetMapTest.java
new file mode 100644
index 0000000000..ef44cf07b4
--- /dev/null
+++ b/plugins/transforms/switchcase/src/test/java/org/apache/hop/pipeline/transforms/switchcase/ContainsKeyToRowSetMapTest.java
@@ -0,0 +1,204 @@
+/*
+ * 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.switchcase;
+
+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.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+
+import java.util.Set;
+import org.apache.hop.core.IRowSet;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for {@link ContainsKeyToRowSetMap}, the substring-matching variant of {@link
+ * KeyToRowSetMap} used when Switch Case is configured with {@code usingContains=true}.
+ *
+ * <p>In this mode, a lookup value matches a stored key if the value <em>contains</em> the key as a
+ * substring, rather than requiring an exact match.
+ */
+class ContainsKeyToRowSetMapTest {
+
+  private IRowSet rowSetA;
+  private IRowSet rowSetB;
+  private ContainsKeyToRowSetMap map;
+
+  @BeforeEach
+  void setUp() {
+    rowSetA = mock(IRowSet.class);
+    rowSetB = mock(IRowSet.class);
+    map = new ContainsKeyToRowSetMap();
+  }
+
+  // ---------------------------------------------------------------------------
+  // get — substring matching
+  // ---------------------------------------------------------------------------
+
+  @Test
+  void get_emptyMap_returnsNull() {
+    assertNull(map.get("anything"));
+  }
+
+  @Test
+  void get_exactMatch_returnsSet() {
+    map.put("hello", rowSetA);
+    assertNotNull(map.get("hello"));
+    assertTrue(map.get("hello").contains(rowSetA));
+  }
+
+  @Test
+  void get_valueContainsKey_returnsSet() {
+    map.put("error", rowSetA);
+    Set<IRowSet> result = map.get("fatal error occurred");
+    assertNotNull(result);
+    assertTrue(result.contains(rowSetA));
+  }
+
+  @Test
+  void get_valueDoesNotContainAnyKey_returnsNull() {
+    map.put("error", rowSetA);
+    assertNull(map.get("all good"));
+  }
+
+  @Test
+  void get_keyAtStartOfValue_matches() {
+    map.put("start", rowSetA);
+    assertNotNull(map.get("start of the string"));
+  }
+
+  @Test
+  void get_keyAtEndOfValue_matches() {
+    map.put("end", rowSetA);
+    assertNotNull(map.get("reached the end"));
+  }
+
+  @Test
+  void get_multipleKeys_returnsFirstInsertedMatch() {
+    // Keys are matched in insertion order; the first key that the value contains is returned
+    map.put("first", rowSetA);
+    map.put("second", rowSetB);
+    // "first and second" contains both keys; insertion order means "first" wins
+    Set<IRowSet> result = map.get("first and second");
+    assertNotNull(result);
+    assertTrue(result.contains(rowSetA));
+  }
+
+  @Test
+  void get_onlySecondKeyMatches_returnsSecondSet() {
+    map.put("alpha", rowSetA);
+    map.put("beta", rowSetB);
+    Set<IRowSet> result = map.get("contains beta only");
+    assertNotNull(result);
+    assertTrue(result.contains(rowSetB));
+  }
+
+  @Test
+  void get_neitherKeyMatches_returnsNull() {
+    map.put("alpha", rowSetA);
+    map.put("beta", rowSetB);
+    assertNull(map.get("gamma delta"));
+  }
+
+  // ---------------------------------------------------------------------------
+  // containsKey — substring matching
+  // ---------------------------------------------------------------------------
+
+  @Test
+  void containsKey_emptyMap_returnsFalse() {
+    assertFalse(map.containsKey("anything"));
+  }
+
+  @Test
+  void containsKey_valueContainsKey_returnsTrue() {
+    map.put("needle", rowSetA);
+    assertTrue(map.containsKey("find the needle here"));
+  }
+
+  @Test
+  void containsKey_exactMatch_returnsTrue() {
+    map.put("needle", rowSetA);
+    assertTrue(map.containsKey("needle"));
+  }
+
+  @Test
+  void containsKey_valueDoesNotContainAnyKey_returnsFalse() {
+    map.put("needle", rowSetA);
+    assertFalse(map.containsKey("no match here"));
+  }
+
+  @Test
+  void containsKey_multipleKeys_trueIfValueContainsAny() {
+    map.put("foo", rowSetA);
+    map.put("bar", rowSetB);
+    assertTrue(map.containsKey("contains bar somewhere"));
+    assertTrue(map.containsKey("contains foo somewhere"));
+    assertFalse(map.containsKey("contains neither"));
+  }
+
+  // ---------------------------------------------------------------------------
+  // put — accumulation and list tracking
+  // ---------------------------------------------------------------------------
+
+  @Test
+  void put_sameKey_accumulatesRowSets() {
+    map.put("key", rowSetA);
+    map.put("key", rowSetB);
+    Set<IRowSet> result = map.get("contains key here");
+    assertNotNull(result);
+    assertEquals(2, result.size());
+    assertTrue(result.contains(rowSetA));
+    assertTrue(result.contains(rowSetB));
+  }
+
+  @Test
+  void put_storesKeyInUnderlyingMap() {
+    map.put("stored", rowSetA);
+    assertFalse(map.isEmpty());
+  }
+
+  @Test
+  void put_keyOnlyStoredOnce_inList_evenIfPutTwice() {
+    // Putting the same key twice adds it to the list twice, which would cause duplicate matches.
+    // This test documents the current behaviour: each put appends to the list independently.
+    map.put("k", rowSetA);
+    map.put("k", rowSetB); // same key, second rowSet
+    // Value that contains "k" should return the accumulated set
+    Set<IRowSet> result = map.get("k-value");
+    assertNotNull(result);
+    assertEquals(2, result.size());
+  }
+
+  // ---------------------------------------------------------------------------
+  // isEmpty (inherited behaviour)
+  // ---------------------------------------------------------------------------
+
+  @Test
+  void isEmpty_newMap_returnsTrue() {
+    assertTrue(map.isEmpty());
+  }
+
+  @Test
+  void isEmpty_afterPut_returnsFalse() {
+    map.put("k", rowSetA);
+    assertFalse(map.isEmpty());
+  }
+}
diff --git a/plugins/transforms/switchcase/src/test/java/org/apache/hop/pipeline/transforms/switchcase/KeyToRowSetMapTest.java b/plugins/transforms/switchcase/src/test/java/org/apache/hop/pipeline/transforms/switchcase/KeyToRowSetMapTest.java
new file mode 100644
index 0000000000..26e4b968ef
--- /dev/null
+++ b/plugins/transforms/switchcase/src/test/java/org/apache/hop/pipeline/transforms/switchcase/KeyToRowSetMapTest.java
@@ -0,0 +1,186 @@
+/*
+ * 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.switchcase;
+
+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.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+
+import java.util.Set;
+import org.apache.hop.core.IRowSet;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class KeyToRowSetMapTest {
+
+  private IRowSet rowSetA;
+  private IRowSet rowSetB;
+  private KeyToRowSetMap map;
+
+  @BeforeEach
+  void setUp() {
+    rowSetA = mock(IRowSet.class);
+    rowSetB = mock(IRowSet.class);
+    map = new KeyToRowSetMap();
+  }
+
+  // ---------------------------------------------------------------------------
+  // isEmpty
+  // ---------------------------------------------------------------------------
+
+  @Test
+  void isEmpty_newMap_returnsTrue() {
+    assertTrue(map.isEmpty());
+  }
+
+  @Test
+  void isEmpty_afterPut_returnsFalse() {
+    map.put("key", rowSetA);
+    assertFalse(map.isEmpty());
+  }
+
+  // ---------------------------------------------------------------------------
+  // get
+  // ---------------------------------------------------------------------------
+
+  @Test
+  void get_missingKey_returnsNull() {
+    assertNull(map.get("missing"));
+  }
+
+  @Test
+  void get_existingKey_returnsSetContainingRowSet() {
+    map.put("k", rowSetA);
+    Set<IRowSet> result = map.get("k");
+    assertNotNull(result);
+    assertTrue(result.contains(rowSetA));
+  }
+
+  @Test
+  void get_differentKeys_returnIndependentSets() {
+    map.put("k1", rowSetA);
+    map.put("k2", rowSetB);
+    assertFalse(map.get("k1").contains(rowSetB));
+    assertFalse(map.get("k2").contains(rowSetA));
+  }
+
+  // ---------------------------------------------------------------------------
+  // put
+  // ---------------------------------------------------------------------------
+
+  @Test
+  void put_sameKey_accumulatesMultipleRowSets() {
+    map.put("k", rowSetA);
+    map.put("k", rowSetB);
+    Set<IRowSet> result = map.get("k");
+    assertEquals(2, result.size());
+    assertTrue(result.contains(rowSetA));
+    assertTrue(result.contains(rowSetB));
+  }
+
+  @Test
+  void put_sameKeyAndSameRowSet_deduplicates() {
+    // The backing collection is a Set — duplicate rowsets for the same key must not be stored twice
+    map.put("k", rowSetA);
+    map.put("k", rowSetA);
+    assertEquals(1, map.get("k").size());
+  }
+
+  @Test
+  void put_multipleKeys_eachKeyHasOwnSet() {
+    map.put("k1", rowSetA);
+    map.put("k2", rowSetB);
+    assertEquals(1, map.get("k1").size());
+    assertEquals(1, map.get("k2").size());
+  }
+
+  // ---------------------------------------------------------------------------
+  // containsKey
+  // ---------------------------------------------------------------------------
+
+  @Test
+  void containsKey_existingKey_returnsTrue() {
+    map.put("k", rowSetA);
+    assertTrue(map.containsKey("k"));
+  }
+
+  @Test
+  void containsKey_missingKey_returnsFalse() {
+    assertFalse(map.containsKey("missing"));
+  }
+
+  @Test
+  void containsKey_emptyMap_returnsFalse() {
+    assertFalse(map.containsKey("anything"));
+  }
+
+  // ---------------------------------------------------------------------------
+  // keySet / entrySet
+  // ---------------------------------------------------------------------------
+
+  @Test
+  void keySet_containsAllInsertedKeys() {
+    map.put("k1", rowSetA);
+    map.put("k2", rowSetB);
+    assertEquals(Set.of("k1", "k2"), map.keySet());
+  }
+
+  @Test
+  void entrySet_sizeMatchesNumberOfDistinctKeys() {
+    map.put("k1", rowSetA);
+    map.put("k1", rowSetB); // same key, different rowset
+    map.put("k2", rowSetA);
+    // 2 distinct keys
+    assertEquals(2, map.entrySet().size());
+  }
+
+  @Test
+  void entrySet_eachEntryHoldsCorrectRowSets() {
+    map.put("k", rowSetA);
+    map.put("k", rowSetB);
+    map.entrySet()
+        .forEach(
+            e -> {
+              assertEquals("k", e.getKey());
+              assertEquals(2, e.getValue().size());
+            });
+  }
+
+  // ---------------------------------------------------------------------------
+  // non-String keys (the map is typed Object, used with pre-processed values)
+  // ---------------------------------------------------------------------------
+
+  @Test
+  void put_integerKey_worksLikeStringKey() {
+    map.put(42, rowSetA);
+    assertTrue(map.containsKey(42));
+    assertNotNull(map.get(42));
+  }
+
+  @Test
+  void put_byteArrayHashCode_worksAsKey() {
+    // SwitchCase converts byte[] via Arrays.hashCode before storing
+    int hashCode = java.util.Arrays.hashCode(new byte[] {1, 2, 3});
+    map.put(hashCode, rowSetA);
+    assertTrue(map.containsKey(hashCode));
+    assertEquals(rowSetA, map.get(hashCode).iterator().next());
+  }
+}
diff --git a/plugins/transforms/switchcase/src/test/java/org/apache/hop/pipeline/transforms/switchcase/SwitchCaseMetaTopologyTest.java b/plugins/transforms/switchcase/src/test/java/org/apache/hop/pipeline/transforms/switchcase/SwitchCaseMetaTopologyTest.java
new file mode 100644
index 0000000000..7d0943fa19
--- /dev/null
+++ b/plugins/transforms/switchcase/src/test/java/org/apache/hop/pipeline/transforms/switchcase/SwitchCaseMetaTopologyTest.java
@@ -0,0 +1,500 @@
+/*
+ * 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.switchcase;
+
+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.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.List;
+import org.apache.hop.core.HopEnvironment;
+import org.apache.hop.core.plugins.PluginRegistry;
+import org.apache.hop.junit.rules.RestoreHopEngineEnvironmentExtension;
+import org.apache.hop.pipeline.transform.ITransformIOMeta;
+import org.apache.hop.pipeline.transform.TransformMeta;
+import org.apache.hop.pipeline.transform.stream.IStream;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.RegisterExtension;
+
+/**
+ * Tests for topology-management methods in {@link SwitchCaseMeta}:
+ *
+ * <ul>
+ *   <li>{@code cleanAfterHopFromRemove} – clears stale target references when a hop is removed
+ *   <li>{@code searchInfoAndTargetTransforms} – resolves stored names → TransformMeta objects
+ *   <li>{@code convertIOMetaToTransformNames} – flushes resolved stream names back to stored fields
+ *   <li>{@code handleStreamSelection} – keeps stored names in sync when a stream is repointed
+ * </ul>
+ */
+class SwitchCaseMetaTopologyTest {
+
+  @RegisterExtension
+  static RestoreHopEngineEnvironmentExtension env = new RestoreHopEngineEnvironmentExtension();
+
+  @BeforeEach
+  void setUp() throws Exception {
+    HopEnvironment.init();
+    PluginRegistry.init();
+  }
+
+  // ---------------------------------------------------------------------------
+  // helpers
+  // ---------------------------------------------------------------------------
+
+  private static SwitchCaseMeta metaWithTargets(
+      String case1Name, String case2Name, String defaultName) {
+    SwitchCaseMeta meta = new SwitchCaseMeta();
+    meta.setFieldName("status");
+    addCaseTarget(meta, "A", case1Name);
+    addCaseTarget(meta, "B", case2Name);
+    meta.setDefaultTargetTransformName(defaultName);
+    return meta;
+  }
+
+  private static void addCaseTarget(SwitchCaseMeta meta, String caseValue, String targetName) {
+    SwitchCaseTarget t = new SwitchCaseTarget();
+    t.setCaseValue(caseValue);
+    t.setCaseTargetTransformName(targetName);
+    meta.getCaseTargets().add(t);
+  }
+
+  /** Minimal TransformMeta – only the name matters for topology look-ups. */
+  private static TransformMeta tm(String name) {
+    return new TransformMeta(name, new SwitchCaseMeta());
+  }
+
+  // ---------------------------------------------------------------------------
+  // cleanAfterHopFromRemove
+  // ---------------------------------------------------------------------------
+
+  @Test
+  void cleanAfterHopFromRemove_returnsFalseForNull() {
+    SwitchCaseMeta meta = metaWithTargets("Out1", "Out2", "Default");
+    assertFalse(meta.cleanAfterHopFromRemove(null));
+    assertEquals("Out1", meta.getCaseTargets().get(0).getCaseTargetTransformName());
+    assertEquals("Default", meta.getDefaultTargetTransformName());
+  }
+
+  @Test
+  void cleanAfterHopFromRemove_returnsFalseWhenNotReferenced() {
+    SwitchCaseMeta meta = metaWithTargets("Out1", "Out2", "Default");
+    assertFalse(meta.cleanAfterHopFromRemove(tm("NotReferenced")));
+    assertEquals("Out1", meta.getCaseTargets().get(0).getCaseTargetTransformName());
+    assertEquals("Out2", meta.getCaseTargets().get(1).getCaseTargetTransformName());
+    assertEquals("Default", meta.getDefaultTargetTransformName());
+  }
+
+  @Test
+  void cleanAfterHopFromRemove_returnsFalseWithEmptyCaseTargets() {
+    SwitchCaseMeta meta = new SwitchCaseMeta();
+    assertFalse(meta.cleanAfterHopFromRemove(tm("Anything")));
+  }
+
+  @Test
+  void cleanAfterHopFromRemove_clearsCaseTargetName() {
+    SwitchCaseMeta meta = metaWithTargets("Out1", "Out2", "Default");
+    assertTrue(meta.cleanAfterHopFromRemove(tm("Out1")));
+    assertNull(meta.getCaseTargets().get(0).getCaseTargetTransformName());
+    assertEquals("Out2", meta.getCaseTargets().get(1).getCaseTargetTransformName());
+    assertEquals("Default", meta.getDefaultTargetTransformName());
+  }
+
+  @Test
+  void cleanAfterHopFromRemove_clearsDefaultTargetName() {
+    SwitchCaseMeta meta = metaWithTargets("Out1", "Out2", "Default");
+    assertTrue(meta.cleanAfterHopFromRemove(tm("Default")));
+    assertNull(meta.getDefaultTargetTransformName());
+    assertEquals("Out1", meta.getCaseTargets().get(0).getCaseTargetTransformName());
+    assertEquals("Out2", meta.getCaseTargets().get(1).getCaseTargetTransformName());
+  }
+
+  @Test
+  void cleanAfterHopFromRemove_clearsAllCaseTargetsWithSameName() {
+    // Two case targets pointing at the same transform — both should be cleared
+    SwitchCaseMeta meta = metaWithTargets("Shared", "Shared", "Default");
+    assertTrue(meta.cleanAfterHopFromRemove(tm("Shared")));
+    assertNull(meta.getCaseTargets().get(0).getCaseTargetTransformName());
+    assertNull(meta.getCaseTargets().get(1).getCaseTargetTransformName());
+    assertEquals("Default", meta.getDefaultTargetTransformName());
+  }
+
+  @Test
+  void cleanAfterHopFromRemove_clearsCaseAndDefaultWhenSameName() {
+    SwitchCaseMeta meta = metaWithTargets("Both", "Out2", "Both");
+    assertTrue(meta.cleanAfterHopFromRemove(tm("Both")));
+    assertNull(meta.getCaseTargets().get(0).getCaseTargetTransformName());
+    assertNull(meta.getDefaultTargetTransformName());
+    assertEquals("Out2", meta.getCaseTargets().get(1).getCaseTargetTransformName());
+  }
+
+  @Test
+  void cleanAfterHopFromRemove_resetsIoMetaCacheOnChange() {
+    SwitchCaseMeta meta = metaWithTargets("Out1", "Out2", "Default");
+    ITransformIOMeta before = meta.getTransformIOMeta();
+    meta.cleanAfterHopFromRemove(tm("Out1"));
+    // The cached IO meta object must be replaced (resetTransformIoMeta was called)
+    assertNotSame(before, meta.getTransformIOMeta());
+  }
+
+  @Test
+  void cleanAfterHopFromRemove_doesNotResetIoMetaWhenNoChange() {
+    SwitchCaseMeta meta = metaWithTargets("Out1", "Out2", "Default");
+    ITransformIOMeta before = meta.getTransformIOMeta();
+    meta.cleanAfterHopFromRemove(tm("NotReferenced"));
+    // No change → same cached object
+    assertNotSame(null, before); // guard
+    // The identity of the IO meta should be unchanged
+    assertEquals(before, meta.getTransformIOMeta());
+  }
+
+  @Test
+  void cleanAfterHopFromRemove_rebuiltIoMetaReflectsClearedName() {
+    SwitchCaseMeta meta = metaWithTargets("Out1", "Out2", "Default");
+    assertEquals(3, meta.getTransformIOMeta().getTargetStreams().size());
+
+    meta.cleanAfterHopFromRemove(tm("Out1"));
+
+    // The case target entry survives (the SwitchCaseTarget itself is kept),
+    // so the rebuilt IO meta still has 3 streams; first stream now has a null subject.
+    List<IStream> streams = meta.getTransformIOMeta().getTargetStreams();
+    assertEquals(3, streams.size());
+    assertNull(streams.get(0).getSubject());
+    assertEquals("Out2", streams.get(1).getSubject());
+    assertEquals("Default", streams.get(2).getSubject());
+  }
+
+  // ---------------------------------------------------------------------------
+  // searchInfoAndTargetTransforms
+  // ---------------------------------------------------------------------------
+
+  @Test
+  void searchInfoAndTargetTransforms_resolvesCaseTargets() {
+    SwitchCaseMeta meta = metaWithTargets("Out1", "Out2", null);
+    meta.setDefaultTargetTransformName(null);
+    TransformMeta out1 = tm("Out1");
+    TransformMeta out2 = tm("Out2");
+
+    meta.searchInfoAndTargetTransforms(List.of(out1, out2));
+
+    List<IStream> streams = meta.getTransformIOMeta().getTargetStreams();
+    assertEquals(out1, streams.get(0).getTransformMeta());
+    assertEquals(out2, streams.get(1).getTransformMeta());
+  }
+
+  @Test
+  void searchInfoAndTargetTransforms_resolvesDefaultTarget() {
+    SwitchCaseMeta meta = metaWithTargets("Out1", "Out2", "Def");
+    TransformMeta def = tm("Def");
+
+    meta.searchInfoAndTargetTransforms(List.of(tm("Out1"), tm("Out2"), def));
+
+    List<IStream> streams = meta.getTransformIOMeta().getTargetStreams();
+    assertEquals(def, streams.get(2).getTransformMeta());
+  }
+
+  @Test
+  void searchInfoAndTargetTransforms_yieldsNullStreamForUnknownName() {
+    SwitchCaseMeta meta = metaWithTargets("Known", "Unknown", null);
+    meta.setDefaultTargetTransformName(null);
+
+    meta.searchInfoAndTargetTransforms(List.of(tm("Known")));
+
+    List<IStream> streams = meta.getTransformIOMeta().getTargetStreams();
+    assertNotNull(streams.get(0).getTransformMeta());
+    assertNull(streams.get(1).getTransformMeta());
+  }
+
+  @Test
+  void searchInfoAndTargetTransforms_handlesEmptyCaseTargets() {
+    SwitchCaseMeta meta = new SwitchCaseMeta();
+    meta.setDefaultTargetTransformName("Def");
+    TransformMeta def = tm("Def");
+
+    meta.searchInfoAndTargetTransforms(List.of(def));
+
+    List<IStream> streams = meta.getTransformIOMeta().getTargetStreams();
+    assertEquals(1, streams.size());
+    assertEquals(def, streams.get(0).getTransformMeta());
+  }
+
+  @Test
+  void searchInfoAndTargetTransforms_afterClean_remainingTargetsStillResolve() {
+    SwitchCaseMeta meta = metaWithTargets("Out1", "Out2", "Default");
+    meta.cleanAfterHopFromRemove(tm("Out1"));
+
+    // "Out1" was cleared; Out2 and Default are still valid
+    meta.searchInfoAndTargetTransforms(List.of(tm("Out2"), tm("Default")));
+
+    List<IStream> streams = meta.getTransformIOMeta().getTargetStreams();
+    assertNull(streams.get(0).getTransformMeta(), "Cleared target should resolve to null");
+    assertNotNull(streams.get(1).getTransformMeta());
+    assertEquals("Out2", streams.get(1).getTransformMeta().getName());
+    assertNotNull(streams.get(2).getTransformMeta());
+    assertEquals("Default", streams.get(2).getTransformMeta().getName());
+  }
+
+  // ---------------------------------------------------------------------------
+  // convertIOMetaToTransformNames
+  // ---------------------------------------------------------------------------
+
+  @Test
+  void convertIOMetaToTransformNames_syncsCaseTargetNamesFromStreams() {
+    SwitchCaseMeta meta = metaWithTargets("Out1", "Out2", null);
+    meta.setDefaultTargetTransformName(null);
+    TransformMeta out1 = tm("Out1");
+    TransformMeta out2 = tm("Out2");
+    meta.searchInfoAndTargetTransforms(List.of(out1, out2));
+
+    // Repoint stream 0 to a new transform (as handleStreamSelection would do during detach)
+    TransformMeta newOut = tm("NewOut1");
+    meta.getTransformIOMeta().getTargetStreams().get(0).setTransformMeta(newOut);
+
+    meta.convertIOMetaToTransformNames();
+
+    assertEquals("NewOut1", meta.getCaseTargets().get(0).getCaseTargetTransformName());
+    assertEquals("Out2", meta.getCaseTargets().get(1).getCaseTargetTransformName());
+  }
+
+  @Test
+  void convertIOMetaToTransformNames_syncsDefaultTargetNameFromStream() {
+    SwitchCaseMeta meta = metaWithTargets("Out1", "Out2", "Def");
+    meta.searchInfoAndTargetTransforms(List.of(tm("Out1"), tm("Out2"), tm("Def")));
+
+    TransformMeta newDef = tm("NewDef");
+    meta.getTransformIOMeta().getTargetStreams().get(2).setTransformMeta(newDef);
+
+    meta.convertIOMetaToTransformNames();
+
+    assertEquals("NewDef", meta.getDefaultTargetTransformName());
+  }
+
+  @Test
+  void convertIOMetaToTransformNames_doesNotUpdateAbsentDefault() {
+    // When defaultTargetTransformName is empty the guard in convertIOMetaToTransformNames
+    // must not overwrite it even if there happened to be a stream at that index.
+    SwitchCaseMeta meta = metaWithTargets("Out1", "Out2", null);
+    meta.setDefaultTargetTransformName(null);
+    meta.searchInfoAndTargetTransforms(List.of(tm("Out1"), tm("Out2")));
+
+    meta.convertIOMetaToTransformNames();
+
+    assertNull(meta.getDefaultTargetTransformName());
+  }
+
+  // ---------------------------------------------------------------------------
+  // handleStreamSelection — repointing existing target streams
+  // ---------------------------------------------------------------------------
+
+  @Test
+  void handleStreamSelection_updatesCaseTargetName() {
+    SwitchCaseMeta meta = metaWithTargets("Old", "Out2", null);
+    meta.setDefaultTargetTransformName(null);
+    meta.searchInfoAndTargetTransforms(List.of(tm("Old"), tm("Out2")));
+
+    TransformMeta newTarget = tm("New");
+    IStream stream = meta.getTransformIOMeta().getTargetStreams().get(0);
+    stream.setTransformMeta(newTarget);
+    stream.setSubject(newTarget.getName());
+    meta.handleStreamSelection(stream);
+
+    assertEquals("New", meta.getCaseTargets().get(0).getCaseTargetTransformName());
+    assertEquals("Out2", meta.getCaseTargets().get(1).getCaseTargetTransformName());
+  }
+
+  @Test
+  void handleStreamSelection_updatesDefaultTargetName() {
+    SwitchCaseMeta meta = metaWithTargets("Out1", "Out2", "OldDefault");
+    meta.searchInfoAndTargetTransforms(List.of(tm("Out1"), tm("Out2"), tm("OldDefault")));
+
+    TransformMeta newDef = tm("NewDefault");
+    IStream defStream = meta.getTransformIOMeta().getTargetStreams().get(2);
+    defStream.setTransformMeta(newDef);
+    defStream.setSubject(newDef.getName());
+    meta.handleStreamSelection(defStream);
+
+    assertEquals("NewDefault", meta.getDefaultTargetTransformName());
+    assertEquals("Out1", meta.getCaseTargets().get(0).getCaseTargetTransformName());
+    assertEquals("Out2", meta.getCaseTargets().get(1).getCaseTargetTransformName());
+  }
+
+  // ---------------------------------------------------------------------------
+  // handleStreamSelection — adding new streams via getOptionalStreams()
+  // ---------------------------------------------------------------------------
+
+  @Test
+  void handleStreamSelection_newCaseTargetStreamAddsEntry() {
+    SwitchCaseMeta meta = new SwitchCaseMeta(); // no targets yet
+
+    // Prime the IO meta cache first — the canvas always renders existing streams before the user
+    // can interact with the "add" placeholder, so the cache is already warm when
+    // handleStreamSelection is called in practice.
+    assertEquals(0, meta.getTransformIOMeta().getTargetStreams().size());
+
+    // newCaseTargetStream is always the last element returned by getOptionalStreams()
+    List<IStream> optional = meta.getOptionalStreams();
+    IStream newCaseTargetStream = optional.get(optional.size() - 1);
+
+    TransformMeta added = tm("CaseX");
+    newCaseTargetStream.setTransformMeta(added);
+    meta.handleStreamSelection(newCaseTargetStream);
+
+    assertEquals(1, meta.getCaseTargets().size());
+    assertEquals("CaseX", meta.getCaseTargets().get(0).getCaseTargetTransformName());
+    // The IO meta should carry exactly the one new stream
+    assertEquals(1, meta.getTransformIOMeta().getTargetStreams().size());
+  }
+
+  @Test
+  void handleStreamSelection_newDefaultStreamSetsDefaultName() {
+    SwitchCaseMeta meta = new SwitchCaseMeta(); // no default yet
+
+    // Prime the IO meta cache (see note in newCaseTargetStream test above)
+    meta.getTransformIOMeta();
+
+    // newDefaultStream is only offered when defaultTargetTransformName is empty
+    List<IStream> optional = meta.getOptionalStreams();
+    IStream newDefaultStream = optional.get(0); // first when no default exists
+
+    TransformMeta defTarget = tm("Default");
+    newDefaultStream.setTransformMeta(defTarget);
+    meta.handleStreamSelection(newDefaultStream);
+
+    assertEquals("Default", meta.getDefaultTargetTransformName());
+  }
+
+  @Test
+  void handleStreamSelection_newDefaultStreamIsNoLongerOfferedAfterSet() {
+    SwitchCaseMeta meta = new SwitchCaseMeta();
+    meta.getTransformIOMeta(); // prime cache
+
+    List<IStream> optional = meta.getOptionalStreams();
+    IStream newDefaultStream = optional.get(0);
+    newDefaultStream.setTransformMeta(tm("Default"));
+    meta.handleStreamSelection(newDefaultStream);
+
+    // Once default is set, getOptionalStreams should only return newCaseTargetStream
+    List<IStream> afterSet = meta.getOptionalStreams();
+    assertEquals(1, afterSet.size());
+  }
+
+  // ---------------------------------------------------------------------------
+  // End-to-end topology simulations
+  // ---------------------------------------------------------------------------
+
+  /**
+   * Simulates inserting a new transform C between Switch Case and B:
+   *
+   * <pre>SwitchCase → B  becomes  SwitchCase → C → B</pre>
+   *
+   * The UI delegate calls handleStreamSelection on the upstream (Switch Case) with its target
+   * stream repointed to C. The stored case-target name must be updated to "C".
+   */
+  @Test
+  void simulation_insertTransformBetweenSwitchCaseAndTarget() {
+    SwitchCaseMeta meta = metaWithTargets("B", "Out2", "Default");
+    meta.searchInfoAndTargetTransforms(List.of(tm("B"), tm("Out2"), tm("Default")));
+
+    // Insert C: detach target stream from B, attach to C
+    TransformMeta c = tm("C");
+    IStream streamToB = meta.getTransformIOMeta().getTargetStreams().get(0);
+    streamToB.setTransformMeta(c);
+    streamToB.setSubject(c.getName());
+    meta.handleStreamSelection(streamToB);
+
+    assertEquals(
+        "C",
+        meta.getCaseTargets().get(0).getCaseTargetTransformName(),
+        "After insert, Switch Case case target must point to the inserted transform C");
+    assertEquals("Out2", meta.getCaseTargets().get(1).getCaseTargetTransformName());
+    assertEquals("Default", meta.getDefaultTargetTransformName());
+
+    // Re-resolve with the new topology (C replaced B in this slot)
+    meta.searchInfoAndTargetTransforms(List.of(c, tm("Out2"), tm("Default")));
+    assertEquals(c, meta.getTransformIOMeta().getTargetStreams().get(0).getTransformMeta());
+  }
+
+  /**
+   * Simulates detaching B from between Switch Case and C:
+   *
+   * <pre>SwitchCase → B → C  becomes  SwitchCase → C</pre>
+   *
+   * The UI delegate calls handleStreamSelection on Switch Case with the target stream repointed to
+   * C. The stored case-target name must be updated from "B" to "C".
+   */
+  @Test
+  void simulation_detachTransformBetweenSwitchCaseAndDownstream() {
+    SwitchCaseMeta meta = metaWithTargets("B", "Out2", "Default");
+    meta.searchInfoAndTargetTransforms(List.of(tm("B"), tm("Out2"), tm("Default")));
+
+    // Detach B, reconnect Switch Case → C
+    TransformMeta c = tm("C");
+    IStream streamToB = meta.getTransformIOMeta().getTargetStreams().get(0);
+    streamToB.setTransformMeta(c);
+    streamToB.setSubject(c.getName());
+    meta.handleStreamSelection(streamToB);
+
+    assertEquals(
+        "C",
+        meta.getCaseTargets().get(0).getCaseTargetTransformName(),
+        "After detach, Switch Case case target must be updated to the new downstream C");
+
+    // searchInfoAndTargetTransforms with the post-detach topology must resolve correctly
+    meta.searchInfoAndTargetTransforms(List.of(tm("B"), c, tm("Out2"), tm("Default")));
+    assertEquals(
+        c,
+        meta.getTransformIOMeta().getTargetStreams().get(0).getTransformMeta(),
+        "Re-resolve must find C, not the detached B");
+  }
+
+  /**
+   * Full round-trip: build → resolve → repoint → re-resolve. Verifies that stored names and
+   * resolved stream objects stay consistent across multiple cycles.
+   */
+  @Test
+  void roundTrip_buildResolveRepointResolve() {
+    SwitchCaseMeta meta = metaWithTargets("A", "B", "Default");
+    TransformMeta tmA = tm("A");
+    TransformMeta tmB = tm("B");
+    TransformMeta tmDefault = tm("Default");
+    meta.searchInfoAndTargetTransforms(List.of(tmA, tmB, tmDefault));
+
+    // Verify initial resolution
+    List<IStream> streams = meta.getTransformIOMeta().getTargetStreams();
+    assertEquals(tmA, streams.get(0).getTransformMeta());
+    assertEquals(tmB, streams.get(1).getTransformMeta());
+    assertEquals(tmDefault, streams.get(2).getTransformMeta());
+
+    // Repoint "A" → "C"
+    TransformMeta tmC = tm("C");
+    streams.get(0).setTransformMeta(tmC);
+    streams.get(0).setSubject(tmC.getName());
+    meta.handleStreamSelection(streams.get(0));
+    assertEquals("C", meta.getCaseTargets().get(0).getCaseTargetTransformName());
+
+    // Re-resolve — "C" now in the pipeline, "A" dropped
+    meta.searchInfoAndTargetTransforms(List.of(tmC, tmB, tmDefault));
+    List<IStream> resolved = meta.getTransformIOMeta().getTargetStreams();
+    assertEquals(tmC, resolved.get(0).getTransformMeta());
+    assertEquals(tmB, resolved.get(1).getTransformMeta());
+    assertEquals(tmDefault, resolved.get(2).getTransformMeta());
+  }
+}
diff --git a/plugins/transforms/switchcase/src/test/java/org/apache/hop/pipeline/transforms/switchcase/SwitchCaseRoutingTest.java b/plugins/transforms/switchcase/src/test/java/org/apache/hop/pipeline/transforms/switchcase/SwitchCaseRoutingTest.java
new file mode 100644
index 0000000000..044092a18f
--- /dev/null
+++ b/plugins/transforms/switchcase/src/test/java/org/apache/hop/pipeline/transforms/switchcase/SwitchCaseRoutingTest.java
@@ -0,0 +1,452 @@
+/*
+ * 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.switchcase;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+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.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Queue;
+import org.apache.hop.core.IRowSet;
+import org.apache.hop.core.exception.HopException;
+import org.apache.hop.core.exception.HopTransformException;
+import org.apache.hop.core.logging.ILoggingObject;
+import org.apache.hop.core.row.IRowMeta;
+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.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests for {@link SwitchCase} routing behaviour — covering {@code createOutputValueMapping} and
+ * the full {@code processRow} flow.
+ *
+ * <p>Unlike the existing {@link SwitchCaseTest} (which only tests {@code prepareObjectType}), these
+ * tests exercise the actual row-routing logic: exact matching, null handling, default fallback,
+ * contains-mode, and error paths.
+ */
+class SwitchCaseRoutingTest {
+
+  private TransformMockHelper<SwitchCaseMeta, SwitchCaseData> helper;
+
+  @BeforeEach
+  void setUp() {
+    helper = new TransformMockHelper<>("Switch Case", SwitchCaseMeta.class, SwitchCaseData.class);
+    when(helper.logChannelFactory.create(any(), any(ILoggingObject.class)))
+        .thenReturn(helper.iLogChannel);
+    when(helper.pipeline.isRunning()).thenReturn(true);
+  }
+
+  @AfterEach
+  void tearDown() {
+    helper.cleanUp();
+  }
+
+  // ---------------------------------------------------------------------------
+  // Test helper
+  // ---------------------------------------------------------------------------
+
+  /**
+   * A minimal {@link SwitchCase} subclass that wires a <em>real</em> {@link SwitchCaseMeta} into
+   * {@link org.apache.hop.pipeline.transform.BaseTransform} (bypassing the Mockito mock in the
+   * helper). This ensures that {@code createOutputValueMapping()} and {@code processRow()} use the
+   * fully-configured metadata rather than a stub.
+   *
+   * <p>Data field setup is done manually (without calling {@code init()}) to avoid the {@link
+   * org.apache.hop.core.plugins.PluginRegistry} dependency: we supply a real {@link
+   * ValueMetaString} for both {@code valueMeta} and {@code stringValueMeta}, and wire the correct
+   * {@link KeyToRowSetMap} variant for the chosen matching mode.
+   */
+  private class SwitchCaseForTest extends SwitchCase {
+
+    final Map<String, IRowSet> rowSets = new HashMap<>();
+    final Queue<Object[]> inputRows = new LinkedList<>();
+    final List<IRowSet> routedTo = new ArrayList<>();
+
+    SwitchCaseForTest(SwitchCaseMeta realMeta) throws Exception {
+      super(
+          helper.transformMeta,
+          realMeta,
+          new SwitchCaseData(),
+          0,
+          helper.pipelineMeta,
+          helper.pipeline);
+
+      // Build a mock input row meta that returns a real ValueMetaString for the switch field.
+      // createOutputValueMapping() uses this to locate the field index and its IValueMeta.
+      IRowMeta mockInputMeta = mock(IRowMeta.class);
+      ValueMetaString fieldMeta = new ValueMetaString(realMeta.getFieldName());
+      when(mockInputMeta.clone()).thenReturn(mockInputMeta);
+      when(mockInputMeta.indexOfValue(anyString())).thenReturn(0);
+      when(mockInputMeta.getValueMeta(anyInt())).thenReturn(fieldMeta);
+
+      // Attach the mock as the single input stream meta
+      setInputRowMeta(mockInputMeta);
+
+      // Manually prime data so we don't need ValueMetaFactory / PluginRegistry.
+      // ValueMetaString handles all String-type conversions natively.
+      SwitchCaseData d = switchCaseData();
+      d.outputMap =
+          realMeta.isUsingContains() ? new ContainsKeyToRowSetMap() : new KeyToRowSetMap();
+      d.valueMeta = new ValueMetaString(realMeta.getFieldName());
+      d.stringValueMeta = new ValueMetaString(realMeta.getFieldName());
+    }
+
+    @Override
+    public IRowSet findOutputRowSet(String targetTransform) {
+      return rowSets.get(targetTransform);
+    }
+
+    @Override
+    public Object[] getRow() {
+      return inputRows.poll();
+    }
+
+    @Override
+    public void putRowTo(IRowMeta rowMeta, Object[] row, IRowSet rowSet)
+        throws HopTransformException {
+      routedTo.add(rowSet);
+    }
+
+    // Expose the protected BaseTransform.data field for assertions in the outer test class.
+    SwitchCaseData switchCaseData() {
+      return data;
+    }
+
+    // Convenience: run processRow() until it returns false (no more rows).
+    void drainRows() throws HopException {
+      //noinspection StatementWithEmptyBody
+      while (processRow()) {}
+    }
+  }
+
+  // ---------------------------------------------------------------------------
+  // helpers
+  // ---------------------------------------------------------------------------
+
+  private static SwitchCaseMeta metaWithCases(String fieldName, SwitchCaseTarget... targets) {
+    SwitchCaseMeta meta = new SwitchCaseMeta();
+    meta.setFieldName(fieldName);
+    for (SwitchCaseTarget t : targets) {
+      meta.getCaseTargets().add(t);
+    }
+    return meta;
+  }
+
+  private static SwitchCaseTarget target(String value, String transformName) {
+    SwitchCaseTarget t = new SwitchCaseTarget();
+    t.setCaseValue(value);
+    t.setCaseTargetTransformName(transformName);
+    return t;
+  }
+
+  // ---------------------------------------------------------------------------
+  // createOutputValueMapping — mapping construction
+  // ---------------------------------------------------------------------------
+
+  @Test
+  void createOutputValueMapping_exactCaseValue_addsToOutputMap() throws Exception {
+    SwitchCaseMeta meta = metaWithCases("status", target("A", "TargetA"), target("B", "TargetB"));
+    SwitchCaseForTest sc = new SwitchCaseForTest(meta);
+    IRowSet rsA = mock(IRowSet.class);
+    IRowSet rsB = mock(IRowSet.class);
+    sc.rowSets.put("TargetA", rsA);
+    sc.rowSets.put("TargetB", rsB);
+
+    sc.createOutputValueMapping();
+
+    assertTrue(
+        sc.switchCaseData().outputMap.containsKey("A"), "Key 'A' should be in the output map");
+    assertTrue(sc.switchCaseData().outputMap.get("A").contains(rsA));
+    assertTrue(sc.switchCaseData().outputMap.containsKey("B"));
+    assertTrue(sc.switchCaseData().outputMap.get("B").contains(rsB));
+  }
+
+  @Test
+  void createOutputValueMapping_defaultTarget_addedToDefaultAndNullSets() throws Exception {
+    SwitchCaseMeta meta = metaWithCases("status", target("A", "TargetA"));
+    meta.setDefaultTargetTransformName("Default");
+    SwitchCaseForTest sc = new SwitchCaseForTest(meta);
+    IRowSet rsA = mock(IRowSet.class);
+    IRowSet rsDefault = mock(IRowSet.class);
+    sc.rowSets.put("TargetA", rsA);
+    sc.rowSets.put("Default", rsDefault);
+
+    sc.createOutputValueMapping();
+
+    assertTrue(sc.switchCaseData().defaultRowSetSet.contains(rsDefault));
+    // When no explicit null target is configured, the default also handles nulls
+    assertTrue(sc.switchCaseData().nullRowSetSet.contains(rsDefault));
+  }
+
+  @Test
+  void createOutputValueMapping_usesKeyToRowSetMapForExactMode() throws Exception {
+    SwitchCaseMeta meta = metaWithCases("x", target("v", "T"));
+    meta.setUsingContains(false);
+    SwitchCaseForTest sc = new SwitchCaseForTest(meta);
+    sc.rowSets.put("T", mock(IRowSet.class));
+
+    sc.createOutputValueMapping();
+
+    assertInstanceOf(KeyToRowSetMap.class, sc.switchCaseData().outputMap);
+  }
+
+  @Test
+  void createOutputValueMapping_usesContainsKeyToRowSetMapForContainsMode() throws Exception {
+    SwitchCaseMeta meta = metaWithCases("x", target("substring", "T"));
+    meta.setUsingContains(true);
+    SwitchCaseForTest sc = new SwitchCaseForTest(meta);
+    sc.rowSets.put("T", mock(IRowSet.class));
+
+    sc.createOutputValueMapping();
+
+    assertInstanceOf(ContainsKeyToRowSetMap.class, sc.switchCaseData().outputMap);
+  }
+
+  @Test
+  void createOutputValueMapping_throwsWhenTargetRowSetNotFound() throws Exception {
+    SwitchCaseMeta meta = metaWithCases("status", target("A", "Missing"));
+    SwitchCaseForTest sc = new SwitchCaseForTest(meta);
+    // "Missing" not registered in sc.rowSets
+
+    assertThrows(HopException.class, sc::createOutputValueMapping);
+  }
+
+  @Test
+  void createOutputValueMapping_throwsWhenFieldNotFoundInRow() throws Exception {
+    SwitchCaseMeta meta = metaWithCases("nonExistentField", target("A", "T"));
+    SwitchCaseForTest sc = new SwitchCaseForTest(meta);
+    sc.rowSets.put("T", mock(IRowSet.class));
+
+    // Override getInputRowMeta to return -1 for the field index (field not found)
+    IRowMeta badMeta = mock(IRowMeta.class);
+    when(badMeta.clone()).thenReturn(badMeta);
+    when(badMeta.indexOfValue(anyString())).thenReturn(-1);
+    sc.setInputRowMeta(badMeta);
+
+    assertThrows(HopException.class, sc::createOutputValueMapping);
+  }
+
+  @Test
+  void createOutputValueMapping_sameTransformForMultipleCaseValues_bothKeysPresent()
+      throws Exception {
+    SwitchCaseMeta meta = metaWithCases("x", target("A", "Shared"), target("B", "Shared"));
+    SwitchCaseForTest sc = new SwitchCaseForTest(meta);
+    IRowSet rsShared = mock(IRowSet.class);
+    sc.rowSets.put("Shared", rsShared);
+
+    sc.createOutputValueMapping();
+
+    assertTrue(sc.switchCaseData().outputMap.get("A").contains(rsShared));
+    assertTrue(sc.switchCaseData().outputMap.get("B").contains(rsShared));
+  }
+
+  // ---------------------------------------------------------------------------
+  // processRow — end-to-end routing
+  // ---------------------------------------------------------------------------
+
+  @Test
+  void processRow_exactMatch_routesToCorrectRowSet() throws Exception {
+    SwitchCaseMeta meta = metaWithCases("col", target("X", "TargetX"), target("Y", "TargetY"));
+    SwitchCaseForTest sc = new SwitchCaseForTest(meta);
+    IRowSet rsX = mock(IRowSet.class);
+    IRowSet rsY = mock(IRowSet.class);
+    sc.rowSets.put("TargetX", rsX);
+    sc.rowSets.put("TargetY", rsY);
+
+    sc.inputRows.add(new Object[] {"X"});
+    sc.inputRows.add(new Object[] {"Y"});
+    sc.inputRows.add(new Object[] {"X"});
+    sc.drainRows();
+
+    assertEquals(3, sc.routedTo.size());
+    assertEquals(rsX, sc.routedTo.get(0));
+    assertEquals(rsY, sc.routedTo.get(1));
+    assertEquals(rsX, sc.routedTo.get(2));
+  }
+
+  @Test
+  void processRow_nullValue_routesToNullRowSetSet() throws Exception {
+    SwitchCaseMeta meta = metaWithCases("col", target("A", "TargetA"));
+    meta.setDefaultTargetTransformName("Default");
+    SwitchCaseForTest sc = new SwitchCaseForTest(meta);
+    IRowSet rsA = mock(IRowSet.class);
+    IRowSet rsDefault = mock(IRowSet.class);
+    sc.rowSets.put("TargetA", rsA);
+    sc.rowSets.put("Default", rsDefault);
+
+    sc.inputRows.add(new Object[] {null}); // null → nullRowSetSet → defaults to rsDefault
+    sc.drainRows();
+
+    assertEquals(1, sc.routedTo.size());
+    assertEquals(rsDefault, sc.routedTo.get(0));
+  }
+
+  @Test
+  void processRow_unmatchedValue_routesToDefault() throws Exception {
+    SwitchCaseMeta meta = metaWithCases("col", target("A", "TargetA"));
+    meta.setDefaultTargetTransformName("Default");
+    SwitchCaseForTest sc = new SwitchCaseForTest(meta);
+    IRowSet rsA = mock(IRowSet.class);
+    IRowSet rsDefault = mock(IRowSet.class);
+    sc.rowSets.put("TargetA", rsA);
+    sc.rowSets.put("Default", rsDefault);
+
+    sc.inputRows.add(new Object[] {"NoMatch"});
+    sc.drainRows();
+
+    assertEquals(1, sc.routedTo.size());
+    assertEquals(rsDefault, sc.routedTo.get(0));
+  }
+
+  @Test
+  void processRow_mixedRows_routedCorrectly() throws Exception {
+    SwitchCaseMeta meta = metaWithCases("col", target("A", "TargetA"), target("B", "TargetB"));
+    meta.setDefaultTargetTransformName("Default");
+    SwitchCaseForTest sc = new SwitchCaseForTest(meta);
+    IRowSet rsA = mock(IRowSet.class);
+    IRowSet rsB = mock(IRowSet.class);
+    IRowSet rsDefault = mock(IRowSet.class);
+    sc.rowSets.put("TargetA", rsA);
+    sc.rowSets.put("TargetB", rsB);
+    sc.rowSets.put("Default", rsDefault);
+
+    sc.inputRows.add(new Object[] {"A"});
+    sc.inputRows.add(new Object[] {"B"});
+    sc.inputRows.add(new Object[] {"unknown"});
+    sc.inputRows.add(new Object[] {null});
+    sc.drainRows();
+
+    assertEquals(4, sc.routedTo.size());
+    assertEquals(rsA, sc.routedTo.get(0));
+    assertEquals(rsB, sc.routedTo.get(1));
+    assertEquals(rsDefault, sc.routedTo.get(2)); // unmatched → default
+    assertEquals(rsDefault, sc.routedTo.get(3)); // null → default (no explicit null target)
+  }
+
+  @Test
+  void processRow_containsMode_routesBySubstringMatch() throws Exception {
+    SwitchCaseMeta meta = metaWithCases("message", target("ERROR", "ErrorTarget"));
+    meta.setUsingContains(true);
+    meta.setDefaultTargetTransformName("Default");
+    SwitchCaseForTest sc = new SwitchCaseForTest(meta);
+    IRowSet rsError = mock(IRowSet.class);
+    IRowSet rsDefault = mock(IRowSet.class);
+    sc.rowSets.put("ErrorTarget", rsError);
+    sc.rowSets.put("Default", rsDefault);
+
+    sc.inputRows.add(new Object[] {"FATAL ERROR: disk full"}); // contains "ERROR"
+    sc.inputRows.add(new Object[] {"INFO: all good"}); // no match
+    sc.drainRows();
+
+    assertEquals(2, sc.routedTo.size());
+    assertEquals(rsError, sc.routedTo.get(0));
+    assertEquals(rsDefault, sc.routedTo.get(1));
+  }
+
+  @Test
+  void processRow_emptyInput_noRowsRouted() throws Exception {
+    SwitchCaseMeta meta = metaWithCases("col", target("A", "TargetA"));
+    SwitchCaseForTest sc = new SwitchCaseForTest(meta);
+    sc.rowSets.put("TargetA", mock(IRowSet.class));
+
+    // No rows added to inputRows
+    sc.drainRows();
+
+    assertTrue(sc.routedTo.isEmpty());
+  }
+
+  @Test
+  void processRow_sameValueForMultipleCases_allTargetRowSetsReceiveRow() throws Exception {
+    // Two case targets with the same value map to different rowsets —
+    // both rowsets should receive the row.
+    SwitchCaseMeta meta = metaWithCases("x", target("same", "T1"), target("same", "T2"));
+    SwitchCaseForTest sc = new SwitchCaseForTest(meta);
+    IRowSet rs1 = mock(IRowSet.class);
+    IRowSet rs2 = mock(IRowSet.class);
+    sc.rowSets.put("T1", rs1);
+    sc.rowSets.put("T2", rs2);
+
+    sc.inputRows.add(new Object[] {"same"});
+    sc.drainRows();
+
+    assertEquals(2, sc.routedTo.size());
+    assertTrue(sc.routedTo.contains(rs1));
+    assertTrue(sc.routedTo.contains(rs2));
+  }
+
+  @Test
+  void processRow_byteArray_routedViaHashCode() throws Exception {
+    // SwitchCase converts byte[] to Arrays.hashCode before map lookup.
+    // Here we register the hash code directly as the case value to confirm the conversion applies.
+    byte[] bytes = {1, 2, 3};
+    int hashCode = java.util.Arrays.hashCode(bytes);
+
+    SwitchCaseMeta meta =
+        metaWithCases("payload", target(String.valueOf(hashCode), "BinaryTarget"));
+    SwitchCaseForTest sc = new SwitchCaseForTest(meta);
+    IRowSet rsBinary = mock(IRowSet.class);
+    sc.rowSets.put("BinaryTarget", rsBinary);
+
+    // The value meta is a String meta, so convertData will convert int → string; we verify
+    // the prepareObjectType path separately via SwitchCase.prepareObjectType tests.
+    // What we test here is that the mechanism doesn't blow up on non-string values in the map.
+    sc.inputRows.add(new Object[] {String.valueOf(hashCode)});
+    sc.drainRows();
+
+    assertEquals(1, sc.routedTo.size());
+    assertEquals(rsBinary, sc.routedTo.get(0));
+  }
+
+  // ---------------------------------------------------------------------------
+  // IValueMeta.TYPE_NONE default (field value type left unspecified)
+  // ---------------------------------------------------------------------------
+
+  @Test
+  void processRow_noExplicitCaseValueType_stringValuesStillRoute() throws Exception {
+    // When no caseValueType is set the meta defaults to TYPE_NONE (empty string).
+    // The routing still works because ValueMetaString.convertDataFromString passes the
+    // string through unchanged, and map.get("A") finds the key.
+    SwitchCaseMeta meta =
+        metaWithCases("field", target("hello", "Hello"), target("world", "World"));
+    SwitchCaseForTest sc = new SwitchCaseForTest(meta);
+    IRowSet rsHello = mock(IRowSet.class);
+    IRowSet rsWorld = mock(IRowSet.class);
+    sc.rowSets.put("Hello", rsHello);
+    sc.rowSets.put("World", rsWorld);
+
+    sc.inputRows.add(new Object[] {"hello"});
+    sc.inputRows.add(new Object[] {"world"});
+    sc.drainRows();
+
+    assertEquals(rsHello, sc.routedTo.get(0));
+    assertEquals(rsWorld, sc.routedTo.get(1));
+  }
+}
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
index 1bb59dc27d..7eb6d01d05 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
@@ -2446,10 +2446,9 @@ public class HopGuiPipelineGraph extends HopGuiAbstractGraph
       pipelineHopDelegate.newHop(
           pipelineMeta, new PipelineHopMeta(newUpstream, toHop.getToTransform()));
 
-      // Same as split hop (insertTransform) but in reverse: point the target's info streams that
-      // were reading from the detached transform to the new upstream so
-      // searchInfoAndTargetTransforms
-      // can resolve correctly.
+      // Input side: point info streams of the new downstream that were reading from the detached
+      // transform to the new upstream, keeping stored source names in sync via
+      // handleStreamSelection.
       TransformMeta targetTransform = toHop.getToTransform();
       if (targetTransform != null
           && targetTransform.getTransform() != null
@@ -2466,13 +2465,53 @@ public class HopGuiPipelineGraph extends HopGuiAbstractGraph
           }
         }
       }
+
+      // Output side: point target streams of the new upstream that were pointing to the detached
+      // transform to the new downstream, keeping stored target names in sync via
+      // handleStreamSelection. This is the mirror of the input-side handling above and ensures
+      // transforms like Switch Case update their stored case-target names correctly on detach.
+      if (newUpstream != null && newUpstream.getTransform() != null) {
+        ITransformIOMeta fromIo = newUpstream.getTransform().getTransformIOMeta();
+        if (fromIo != null) {
+          TransformMeta newDownstream = toHop.getToTransform();
+          for (IStream stream : fromIo.getTargetStreams()) {
+            if (stream.getTransformMeta() != null
+                && stream.getTransformMeta().equals(transformMeta)
+                && newDownstream != null) {
+              stream.setTransformMeta(newDownstream);
+              stream.setSubject(newDownstream.getName());
+              newUpstream.getTransform().handleStreamSelection(stream);
+            }
+          }
+        }
+      }
+    }
+
+    // For upstream transforms that had a hop pointing TO the detached transform, clear the stale
+    // target reference when it was not already redirected via handleStreamSelection above.
+    // This covers the "leaf detach" case (no toHop) where no new downstream exists to reconnect to.
+    for (PipelineHopMeta hop : removedHops) {
+      TransformMeta from = hop.getFromTransform();
+      TransformMeta to = hop.getToTransform();
+      if (to != null
+          && to.equals(transformMeta)
+          && from != null
+          && !from.equals(transformMeta)
+          && from.getTransform() != null) {
+        boolean handledByReconnect = fromHop != null && toHop != null && hop == fromHop;
+        if (!handledByReconnect) {
+          from.getTransform().cleanAfterHopFromRemove(transformMeta);
+        }
+      }
     }
 
-    // Same as split hop (insertTransform): after topology change, trigger IO meta update on
-    // targets of removed hops so linked transform names are updated.
+    // After the topology change, let the downstream transforms re-resolve their info/target stream
+    // names against the updated pipeline.
     for (PipelineHopMeta hop : removedHops) {
       TransformMeta toTransform = hop.getToTransform();
-      if (toTransform != null && toTransform.getTransform() != null) {
+      if (toTransform != null
+          && toTransform.getTransform() != null
+          && !toTransform.equals(transformMeta)) {
         toTransform.getTransform().searchInfoAndTargetTransforms(pipelineMeta.getTransforms());
       }
     }
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/delegates/HopGuiPipelineHopDelegate.java b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/delegates/HopGuiPipelineHopDelegate.java
index 591d25e382..ee6accf014 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/delegates/HopGuiPipelineHopDelegate.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/delegates/HopGuiPipelineHopDelegate.java
@@ -230,6 +230,9 @@ public class HopGuiPipelineHopDelegate {
 
     boolean transformFromNeedAddUndoChange =
         fromTransformMeta.getTransform().cleanAfterHopFromRemove(pipelineHopMeta.getToTransform());
+    if (fromTransformMeta.getTransform() != null) {
+      fromTransformMeta.getTransform().searchInfoAndTargetTransforms(pipelineMeta.getTransforms());
+    }
     boolean transformToNeedAddUndoChange =
         toTransformMeta.getTransform().cleanAfterHopToRemove(fromTransformMeta);
 
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/delegates/HopGuiPipelineTransformDelegate.java b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/delegates/HopGuiPipelineTransformDelegate.java
index 3cb0647a62..c15dcf3d6d 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/delegates/HopGuiPipelineTransformDelegate.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/delegates/HopGuiPipelineTransformDelegate.java
@@ -728,6 +728,9 @@ public class HopGuiPipelineTransformDelegate {
           }
           if (!transforms.contains(fromTransform) && fromTransform.getTransform() != null) {
             fromTransform.getTransform().cleanAfterHopFromRemove(toTransform);
+            fromTransform
+                .getTransform()
+                .searchInfoAndTargetTransforms(pipelineMeta.getTransforms());
           }
 
           pipelineMeta.removePipelineHop(idx);