/*
 * 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.beam.runners.core.construction.graph;

import static com.google.common.collect.Iterables.getOnlyElement;
import static org.hamcrest.Matchers.anyOf;
import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.emptyIterable;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.hasItems;
import static org.hamcrest.Matchers.hasSize;
import static org.hamcrest.Matchers.not;
import static org.hamcrest.Matchers.nullValue;
import static org.junit.Assert.assertThat;

import java.util.Collection;
import java.util.HashSet;
import java.util.Set;
import java.util.stream.Collectors;
import org.apache.beam.model.pipeline.v1.RunnerApi;
import org.apache.beam.model.pipeline.v1.RunnerApi.Coder;
import org.apache.beam.model.pipeline.v1.RunnerApi.Components;
import org.apache.beam.model.pipeline.v1.RunnerApi.Environment;
import org.apache.beam.model.pipeline.v1.RunnerApi.FunctionSpec;
import org.apache.beam.model.pipeline.v1.RunnerApi.PCollection;
import org.apache.beam.model.pipeline.v1.RunnerApi.PTransform;
import org.apache.beam.model.pipeline.v1.RunnerApi.ParDoPayload;
import org.apache.beam.model.pipeline.v1.RunnerApi.Pipeline;
import org.apache.beam.model.pipeline.v1.RunnerApi.SdkFunctionSpec;
import org.apache.beam.model.pipeline.v1.RunnerApi.SideInput;
import org.apache.beam.model.pipeline.v1.RunnerApi.StateSpec;
import org.apache.beam.model.pipeline.v1.RunnerApi.TimerSpec;
import org.apache.beam.model.pipeline.v1.RunnerApi.WindowIntoPayload;
import org.apache.beam.model.pipeline.v1.RunnerApi.WindowingStrategy;
import org.apache.beam.runners.core.construction.PTransformTranslation;
import org.apache.beam.runners.core.construction.graph.PipelineNode.PCollectionNode;
import org.apache.beam.runners.core.construction.graph.PipelineNode.PTransformNode;
import org.hamcrest.Matchers;
import org.hamcrest.core.AnyOf;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.JUnit4;

/** Tests for {@link GreedyPipelineFuser}. */
@RunWith(JUnit4.class)
public class GreedyPipelineFuserTest {
  // Contains the 'go' and 'py' environments, and a default 'impulse' step and output.
  private Components partialComponents;

  @Before
  public void setup() {
    partialComponents =
        Components.newBuilder()
            .putTransforms(
                "impulse",
                PTransform.newBuilder()
                    .setUniqueName("Impulse")
                    .putOutputs("output", "impulse.out")
                    .setSpec(
                        FunctionSpec.newBuilder()
                            .setUrn(PTransformTranslation.IMPULSE_TRANSFORM_URN))
                    .build())
            .putPcollections("impulse.out", pc("impulse.out"))
            .putEnvironments("go", Environment.newBuilder().setUrl("go").build())
            .putEnvironments("py", Environment.newBuilder().setUrl("py").build())
            .putCoders("coder", Coder.newBuilder().build())
            .putCoders("windowCoder", Coder.newBuilder().build())
            .putWindowingStrategies(
                "ws", WindowingStrategy.newBuilder().setWindowCoderId("windowCoder").build())
            .build();
  }

  private static PCollection pc(String name) {
    return PCollection.newBuilder()
        .setUniqueName(name)
        .setCoderId("coder")
        .setWindowingStrategyId("ws")
        .build();
  }

  /*
   * impulse -> .out -> read -> .out -> parDo -> .out -> window -> .out
   * becomes
   * (impulse.out) -> read -> read.out -> parDo -> parDo.out -> window
   */
  @Test
  public void singleEnvironmentBecomesASingleStage() {
    String name = "read.out";
    Components components =
        partialComponents
            .toBuilder()
            .putTransforms(
                "read",
                PTransform.newBuilder()
                    .setUniqueName("Read")
                    .putInputs("input", "impulse.out")
                    .putOutputs("output", "read.out")
                    .setSpec(
                        FunctionSpec.newBuilder()
                            .setUrn(PTransformTranslation.PAR_DO_TRANSFORM_URN)
                            .setPayload(
                                ParDoPayload.newBuilder()
                                    .setDoFn(SdkFunctionSpec.newBuilder().setEnvironmentId("py"))
                                    .build()
                                    .toByteString()))
                    .build())
            .putPcollections("read.out", pc(name))
            .putTransforms(
                "parDo",
                PTransform.newBuilder()
                    .setUniqueName("ParDo")
                    .putInputs("input", "read.out")
                    .putOutputs("output", "parDo.out")
                    .setSpec(
                        FunctionSpec.newBuilder()
                            .setUrn(PTransformTranslation.PAR_DO_TRANSFORM_URN)
                            .setPayload(
                                ParDoPayload.newBuilder()
                                    .setDoFn(SdkFunctionSpec.newBuilder().setEnvironmentId("py"))
                                    .build()
                                    .toByteString()))
                    .build())
            .putPcollections("parDo.out", pc("parDo.out"))
            .putTransforms(
                "window",
                PTransform.newBuilder()
                    .setUniqueName("Window")
                    .putInputs("input", "parDo.out")
                    .putOutputs("output", "window.out")
                    .setSpec(
                        FunctionSpec.newBuilder()
                            .setUrn(PTransformTranslation.ASSIGN_WINDOWS_TRANSFORM_URN)
                            .setPayload(
                                WindowIntoPayload.newBuilder()
                                    .setWindowFn(
                                        SdkFunctionSpec.newBuilder().setEnvironmentId("py"))
                                    .build()
                                    .toByteString()))
                    .build())
            .putPcollections("window.out", pc("window.out"))
            .build();
    FusedPipeline fused =
        GreedyPipelineFuser.fuse(Pipeline.newBuilder().setComponents(components).build());

    assertThat(
        fused.getRunnerExecutedTransforms(),
        contains(PipelineNode.pTransform("impulse", components.getTransformsOrThrow("impulse"))));
    assertThat(
        fused.getFusedStages(),
        contains(
            ExecutableStageMatcher.withInput("impulse.out")
                .withNoOutputs()
                .withTransforms("read", "parDo", "window")));
  }

  /*
   * impulse -> .out -> mystery -> .out
   *                 \
   *                  -> enigma -> .out
   * becomes all runner-executed
   */
  @Test
  public void unknownTransformsNoEnvironmentBecomeRunnerExecuted() {
    Components components =
        partialComponents
            .toBuilder()
            .putTransforms(
                "mystery",
                PTransform.newBuilder()
                    .setUniqueName("Mystery")
                    .putInputs("input", "impulse.out")
                    .putOutputs("output", "mystery.out")
                    .setSpec(FunctionSpec.newBuilder().setUrn("beam:transform:mystery:v1.4"))
                    .build())
            .putPcollections("mystery.out", pc("mystery.out"))
            .putTransforms(
                "enigma",
                PTransform.newBuilder()
                    .setUniqueName("Enigma")
                    .putInputs("input", "impulse.out")
                    .putOutputs("output", "enigma.out")
                    .setSpec(FunctionSpec.newBuilder().setUrn("beam:transform:enigma:v1"))
                    .build())
            .putPcollections("enigma.out", pc("enigma.out"))
            .build();
    FusedPipeline fused =
        GreedyPipelineFuser.fuse(Pipeline.newBuilder().setComponents(components).build());

    assertThat(
        fused.getRunnerExecutedTransforms(),
        containsInAnyOrder(
            PipelineNode.pTransform("impulse", components.getTransformsOrThrow("impulse")),
            PipelineNode.pTransform("mystery", components.getTransformsOrThrow("mystery")),
            PipelineNode.pTransform("enigma", components.getTransformsOrThrow("enigma"))));
    assertThat(fused.getFusedStages(), emptyIterable());
  }

  /*
   * impulse -> .out -> read -> .out -> groupByKey -> .out -> parDo -> .out
   * becomes
   * (impulse.out) -> read -> (read.out)
   * (groupByKey.out) -> parDo
   */
  @Test
  public void singleEnvironmentAcrossGroupByKeyMultipleStages() {
    Components components =
        partialComponents
            .toBuilder()
            .putTransforms(
                "read",
                PTransform.newBuilder()
                    .setUniqueName("Read")
                    .putInputs("input", "impulse.out")
                    .putOutputs("output", "read.out")
                    .setSpec(
                        FunctionSpec.newBuilder()
                            .setUrn(PTransformTranslation.PAR_DO_TRANSFORM_URN)
                            .setPayload(
                                ParDoPayload.newBuilder()
                                    .setDoFn(SdkFunctionSpec.newBuilder().setEnvironmentId("py"))
                                    .build()
                                    .toByteString()))
                    .build())
            .putPcollections("read.out", pc("read.out"))
            .putTransforms(
                "groupByKey",
                PTransform.newBuilder()
                    .setUniqueName("GroupByKey")
                    .putInputs("input", "read.out")
                    .putOutputs("output", "groupByKey.out")
                    .setSpec(
                        FunctionSpec.newBuilder()
                            .setUrn(PTransformTranslation.GROUP_BY_KEY_TRANSFORM_URN))
                    .build())
            .putPcollections("groupByKey.out", pc("groupByKey.out"))
            .putTransforms(
                "parDo",
                PTransform.newBuilder()
                    .setUniqueName("ParDo")
                    .putInputs("input", "groupByKey.out")
                    .putOutputs("output", "parDo.out")
                    .setSpec(
                        FunctionSpec.newBuilder()
                            .setUrn(PTransformTranslation.PAR_DO_TRANSFORM_URN)
                            .setPayload(
                                ParDoPayload.newBuilder()
                                    .setDoFn(SdkFunctionSpec.newBuilder().setEnvironmentId("py"))
                                    .build()
                                    .toByteString()))
                    .build())
            .putPcollections("parDo.out", pc("parDo.out"))
            .build();
    FusedPipeline fused =
        GreedyPipelineFuser.fuse(Pipeline.newBuilder().setComponents(components).build());

    assertThat(
        fused.getRunnerExecutedTransforms(),
        containsInAnyOrder(
            PipelineNode.pTransform("impulse", components.getTransformsOrThrow("impulse")),
            PipelineNode.pTransform("groupByKey", components.getTransformsOrThrow("groupByKey"))));
    assertThat(
        fused.getFusedStages(),
        containsInAnyOrder(
            ExecutableStageMatcher.withInput("impulse.out")
                .withOutputs("read.out")
                .withTransforms("read"),
            ExecutableStageMatcher.withInput("groupByKey.out")
                .withNoOutputs()
                .withTransforms("parDo")));
  }

  /*
   * impulse -> .out -> read -> .out --> goTransform -> .out
   *                                  \
   *                                   -> pyTransform -> .out
   * becomes (impulse.out) -> read -> (read.out)
   *         (read.out) -> goTransform
   *         (read.out) -> pyTransform
   */
  @Test
  public void multipleEnvironmentsBecomesMultipleStages() {
    Components components =
        partialComponents
            .toBuilder()
            .putTransforms(
                "read",
                PTransform.newBuilder()
                    .setUniqueName("Read")
                    .putInputs("input", "impulse.out")
                    .putOutputs("output", "read.out")
                    .setSpec(
                        FunctionSpec.newBuilder()
                            .setUrn(PTransformTranslation.PAR_DO_TRANSFORM_URN)
                            .setPayload(
                                ParDoPayload.newBuilder()
                                    .setDoFn(SdkFunctionSpec.newBuilder().setEnvironmentId("py"))
                                    .build()
                                    .toByteString()))
                    .build())
            .putPcollections("read.out", pc("read.out"))
            .putTransforms(
                "goTransform",
                PTransform.newBuilder()
                    .setUniqueName("GoTransform")
                    .putInputs("input", "read.out")
                    .putOutputs("output", "go.out")
                    .setSpec(
                        FunctionSpec.newBuilder()
                            .setUrn(PTransformTranslation.PAR_DO_TRANSFORM_URN)
                            .setPayload(
                                ParDoPayload.newBuilder()
                                    .setDoFn(SdkFunctionSpec.newBuilder().setEnvironmentId("go"))
                                    .build()
                                    .toByteString()))
                    .build())
            .putPcollections("go.out", pc("go.out"))
            .putTransforms(
                "pyTransform",
                PTransform.newBuilder()
                    .setUniqueName("PyTransform")
                    .putInputs("input", "read.out")
                    .putOutputs("output", "py.out")
                    .setSpec(
                        FunctionSpec.newBuilder()
                            .setUrn(PTransformTranslation.ASSIGN_WINDOWS_TRANSFORM_URN)
                            .setPayload(
                                WindowIntoPayload.newBuilder()
                                    .setWindowFn(
                                        SdkFunctionSpec.newBuilder().setEnvironmentId("py"))
                                    .build()
                                    .toByteString()))
                    .build())
            .putPcollections("py.out", pc("py.out"))
            .build();
    FusedPipeline fused =
        GreedyPipelineFuser.fuse(Pipeline.newBuilder().setComponents(components).build());

    // Impulse is the runner transform
    assertThat(fused.getRunnerExecutedTransforms(), hasSize(1));
    assertThat(fused.getFusedStages(), hasSize(3));
    assertThat(
        fused.getFusedStages(),
        containsInAnyOrder(
            ExecutableStageMatcher.withInput("impulse.out")
                .withOutputs("read.out")
                .withTransforms("read"),
            ExecutableStageMatcher.withInput("read.out")
                .withNoOutputs()
                .withTransforms("pyTransform"),
            ExecutableStageMatcher.withInput("read.out")
                .withNoOutputs()
                .withTransforms("goTransform")));
  }

  /*
   * goImpulse -> .out -> goRead -> .out \                    -> goParDo -> .out
   *                                      -> flatten -> .out |
   * pyImpulse -> .out -> pyRead -> .out /                    -> pyParDo -> .out
   *
   * becomes
   * (goImpulse.out) -> goRead -> goRead.out -> flatten -> (flatten.out_synthetic0)
   * (pyImpulse.out) -> pyRead -> pyRead.out -> flatten -> (flatten.out_synthetic1)
   * flatten.out_synthetic0 & flatten.out_synthetic1 -> synthetic_flatten -> flatten.out
   * (flatten.out) -> goParDo
   * (flatten.out) -> pyParDo
   */
  @Test
  public void flattenWithHeterogenousInputsAndOutputsEntirelyMaterialized() {
    Components components =
        Components.newBuilder()
            .putCoders("coder", Coder.newBuilder().build())
            .putCoders("windowCoder", Coder.newBuilder().build())
            .putWindowingStrategies(
                "ws", WindowingStrategy.newBuilder().setWindowCoderId("windowCoder").build())
            .putTransforms(
                "pyImpulse",
                PTransform.newBuilder()
                    .setUniqueName("PyImpulse")
                    .putOutputs("output", "pyImpulse.out")
                    .setSpec(
                        FunctionSpec.newBuilder()
                            .setUrn(PTransformTranslation.IMPULSE_TRANSFORM_URN))
                    .build())
            .putPcollections("pyImpulse.out", pc("pyImpulse.out"))
            .putTransforms(
                "pyRead",
                PTransform.newBuilder()
                    .setUniqueName("PyRead")
                    .putInputs("input", "pyImpulse.out")
                    .putOutputs("output", "pyRead.out")
                    .setSpec(
                        FunctionSpec.newBuilder()
                            .setUrn(PTransformTranslation.PAR_DO_TRANSFORM_URN)
                            .setPayload(
                                ParDoPayload.newBuilder()
                                    .setDoFn(SdkFunctionSpec.newBuilder().setEnvironmentId("py"))
                                    .build()
                                    .toByteString()))
                    .build())
            .putPcollections("pyRead.out", pc("pyRead.out"))
            .putTransforms(
                "goImpulse",
                PTransform.newBuilder()
                    .setUniqueName("GoImpulse")
                    .putOutputs("output", "goImpulse.out")
                    .setSpec(
                        FunctionSpec.newBuilder()
                            .setUrn(PTransformTranslation.IMPULSE_TRANSFORM_URN))
                    .build())
            .putPcollections("goImpulse.out", pc("goImpulse.out"))
            .putTransforms(
                "goRead",
                PTransform.newBuilder()
                    .setUniqueName("GoRead")
                    .putInputs("input", "goImpulse.out")
                    .putOutputs("output", "goRead.out")
                    .setSpec(
                        FunctionSpec.newBuilder()
                            .setUrn(PTransformTranslation.PAR_DO_TRANSFORM_URN)
                            .setPayload(
                                ParDoPayload.newBuilder()
                                    .setDoFn(SdkFunctionSpec.newBuilder().setEnvironmentId("go"))
                                    .build()
                                    .toByteString()))
                    .build())
            .putPcollections("goRead.out", pc("goRead.out"))
            .putTransforms(
                "flatten",
                PTransform.newBuilder()
                    .setUniqueName("Flatten")
                    .putInputs("goReadInput", "goRead.out")
                    .putInputs("pyReadInput", "pyRead.out")
                    .putOutputs("output", "flatten.out")
                    .setSpec(
                        FunctionSpec.newBuilder()
                            .setUrn(PTransformTranslation.FLATTEN_TRANSFORM_URN))
                    .build())
            .putPcollections("flatten.out", pc("flatten.out"))
            .putTransforms(
                "pyParDo",
                PTransform.newBuilder()
                    .setUniqueName("PyParDo")
                    .putInputs("input", "flatten.out")
                    .putOutputs("output", "pyParDo.out")
                    .setSpec(
                        FunctionSpec.newBuilder()
                            .setUrn(PTransformTranslation.PAR_DO_TRANSFORM_URN)
                            .setPayload(
                                ParDoPayload.newBuilder()
                                    .setDoFn(SdkFunctionSpec.newBuilder().setEnvironmentId("py"))
                                    .build()
                                    .toByteString()))
                    .build())
            .putPcollections("pyParDo.out", pc("pyParDo.out"))
            .putTransforms(
                "goParDo",
                PTransform.newBuilder()
                    .setUniqueName("GoParDo")
                    .putInputs("input", "flatten.out")
                    .putOutputs("output", "goParDo.out")
                    .setSpec(
                        FunctionSpec.newBuilder()
                            .setUrn(PTransformTranslation.PAR_DO_TRANSFORM_URN)
                            .setPayload(
                                ParDoPayload.newBuilder()
                                    .setDoFn(SdkFunctionSpec.newBuilder().setEnvironmentId("go"))
                                    .build()
                                    .toByteString()))
                    .build())
            .putPcollections("goParDo.out", pc("goParDo.out"))
            .putEnvironments("go", Environment.newBuilder().setUrl("go").build())
            .putEnvironments("py", Environment.newBuilder().setUrl("py").build())
            .build();
    FusedPipeline fused =
        GreedyPipelineFuser.fuse(Pipeline.newBuilder().setComponents(components).build());

    assertThat(fused.getRunnerExecutedTransforms(), hasSize(3));
    assertThat(
        "The runner should include the impulses for both languages, plus an introduced flatten",
        fused.getRunnerExecutedTransforms(),
        hasItems(
            PipelineNode.pTransform("pyImpulse", components.getTransformsOrThrow("pyImpulse")),
            PipelineNode.pTransform("goImpulse", components.getTransformsOrThrow("goImpulse"))));

    PTransformNode flattenNode = null;
    for (PTransformNode runnerTransform : fused.getRunnerExecutedTransforms()) {
      if (getOnlyElement(runnerTransform.getTransform().getOutputsMap().values())
          .equals("flatten.out")) {
        flattenNode = runnerTransform;
      }
    }

    assertThat(flattenNode, not(nullValue()));
    assertThat(
        flattenNode.getTransform().getSpec().getUrn(),
        equalTo(PTransformTranslation.FLATTEN_TRANSFORM_URN));
    assertThat(new HashSet<>(flattenNode.getTransform().getInputsMap().values()), hasSize(2));

    Collection<String> introducedOutputs = flattenNode.getTransform().getInputsMap().values();
    AnyOf<String> anyIntroducedPCollection =
        anyOf(introducedOutputs.stream().map(Matchers::equalTo).collect(Collectors.toSet()));
    assertThat(
        fused.getFusedStages(),
        containsInAnyOrder(
            ExecutableStageMatcher.withInput("goImpulse.out")
                .withOutputs(anyIntroducedPCollection)
                .withTransforms("goRead", "flatten"),
            ExecutableStageMatcher.withInput("pyImpulse.out")
                .withOutputs(anyIntroducedPCollection)
                .withTransforms("pyRead", "flatten"),
            ExecutableStageMatcher.withInput("flatten.out")
                .withNoOutputs()
                .withTransforms("goParDo"),
            ExecutableStageMatcher.withInput("flatten.out")
                .withNoOutputs()
                .withTransforms("pyParDo")));
    Set<String> materializedStageOutputs =
        fused
            .getFusedStages()
            .stream()
            .flatMap(executableStage -> executableStage.getOutputPCollections().stream())
            .map(PCollectionNode::getId)
            .collect(Collectors.toSet());

    assertThat(
        "All materialized stage outputs should be flattened, and no more",
        materializedStageOutputs,
        containsInAnyOrder(
            flattenNode.getTransform().getInputsMap().values().toArray(new String[0])));
  }

  /*
   * impulseA -> .out -> goRead -> .out \
   *                                     -> flatten -> .out -> goParDo -> .out
   * impulseB -> .out -> pyRead -> .out /
   *
   * becomes
   * (impulseA.out) -> goRead -> goRead.out -> flatten -> flatten.out -> goParDo
   * (impulseB.out) -> pyRead -> pyRead.out -> flatten -> (flatten.out)
   * (flatten.out) -> goParDo
   */
  @Test
  public void flattenWithHeterogeneousInputsSingleEnvOutputPartiallyMaterialized() {
    Components components =
        Components.newBuilder()
            .putCoders("coder", Coder.newBuilder().build())
            .putCoders("windowCoder", Coder.newBuilder().build())
            .putWindowingStrategies(
                "ws", WindowingStrategy.newBuilder().setWindowCoderId("windowCoder").build())
            .putTransforms(
                "pyImpulse",
                PTransform.newBuilder()
                    .setUniqueName("PyImpulse")
                    .putOutputs("output", "pyImpulse.out")
                    .setSpec(
                        FunctionSpec.newBuilder()
                            .setUrn(PTransformTranslation.IMPULSE_TRANSFORM_URN))
                    .build())
            .putPcollections("pyImpulse.out", pc("pyImpulse.out"))
            .putTransforms(
                "pyRead",
                PTransform.newBuilder()
                    .setUniqueName("PyRead")
                    .putInputs("input", "pyImpulse.out")
                    .putOutputs("output", "pyRead.out")
                    .setSpec(
                        FunctionSpec.newBuilder()
                            .setUrn(PTransformTranslation.PAR_DO_TRANSFORM_URN)
                            .setPayload(
                                ParDoPayload.newBuilder()
                                    .setDoFn(SdkFunctionSpec.newBuilder().setEnvironmentId("py"))
                                    .build()
                                    .toByteString()))
                    .build())
            .putPcollections("pyRead.out", pc("pyRead.out"))
            .putTransforms(
                "goImpulse",
                PTransform.newBuilder()
                    .setUniqueName("GoImpulse")
                    .putOutputs("output", "goImpulse.out")
                    .setSpec(
                        FunctionSpec.newBuilder()
                            .setUrn(PTransformTranslation.IMPULSE_TRANSFORM_URN))
                    .build())
            .putPcollections("goImpulse.out", pc("goImpulse.out"))
            .putTransforms(
                "goRead",
                PTransform.newBuilder()
                    .setUniqueName("GoRead")
                    .putInputs("input", "goImpulse.out")
                    .putOutputs("output", "goRead.out")
                    .setSpec(
                        FunctionSpec.newBuilder()
                            .setUrn(PTransformTranslation.PAR_DO_TRANSFORM_URN)
                            .setPayload(
                                ParDoPayload.newBuilder()
                                    .setDoFn(SdkFunctionSpec.newBuilder().setEnvironmentId("go"))
                                    .build()
                                    .toByteString()))
                    .build())
            .putPcollections("goRead.out", pc("goRead.out"))
            .putTransforms(
                "flatten",
                PTransform.newBuilder()
                    .setUniqueName("Flatten")
                    .putInputs("goReadInput", "goRead.out")
                    .putInputs("pyReadInput", "pyRead.out")
                    .putOutputs("output", "flatten.out")
                    .setSpec(
                        FunctionSpec.newBuilder()
                            .setUrn(PTransformTranslation.FLATTEN_TRANSFORM_URN))
                    .build())
            .putPcollections("flatten.out", pc("flatten.out"))
            .putTransforms(
                "goParDo",
                PTransform.newBuilder()
                    .setUniqueName("GoParDo")
                    .putInputs("input", "flatten.out")
                    .putOutputs("output", "goParDo.out")
                    .setSpec(
                        FunctionSpec.newBuilder()
                            .setUrn(PTransformTranslation.PAR_DO_TRANSFORM_URN)
                            .setPayload(
                                ParDoPayload.newBuilder()
                                    .setDoFn(SdkFunctionSpec.newBuilder().setEnvironmentId("go"))
                                    .build()
                                    .toByteString()))
                    .build())
            .putPcollections("goParDo.out", pc("goParDo.out"))
            .putEnvironments("go", Environment.newBuilder().setUrl("go").build())
            .putEnvironments("py", Environment.newBuilder().setUrl("py").build())
            .build();
    FusedPipeline fused =
        GreedyPipelineFuser.fuse(Pipeline.newBuilder().setComponents(components).build());

    assertThat(
        fused.getRunnerExecutedTransforms(),
        containsInAnyOrder(
            PipelineNode.pTransform("pyImpulse", components.getTransformsOrThrow("pyImpulse")),
            PipelineNode.pTransform("goImpulse", components.getTransformsOrThrow("goImpulse"))));

    assertThat(
        fused.getFusedStages(),
        containsInAnyOrder(
            ExecutableStageMatcher.withInput("goImpulse.out")
                .withNoOutputs()
                .withTransforms("goRead", "flatten", "goParDo"),
            ExecutableStageMatcher.withInput("pyImpulse.out")
                .withOutputs("flatten.out")
                .withTransforms("pyRead", "flatten"),
            ExecutableStageMatcher.withInput("flatten.out")
                .withNoOutputs()
                .withTransforms("goParDo")));
  }

  /*
   * impulseA -> .out -> flatten -> .out -> read -> .out -> parDo -> .out
   * becomes
   * (flatten.out) -> read -> parDo
   *
   * Flatten, specifically, doesn't fuse greedily into downstream environments or act as a sibling
   * to any of those nodes, but the routing is instead handled by the Runner.
   */
  @Test
  public void flattenAfterNoEnvDoesNotFuse() {
    Components components =
        partialComponents
            .toBuilder()
            .putTransforms(
                "flatten",
                PTransform.newBuilder()
                    .setUniqueName("Flatten")
                    .putInputs("impulseInput", "impulse.out")
                    .putOutputs("output", "flatten.out")
                    .setSpec(
                        FunctionSpec.newBuilder()
                            .setUrn(PTransformTranslation.FLATTEN_TRANSFORM_URN)
                            .build())
                    .build())
            .putPcollections("flatten.out", pc("flatten.out"))
            .putTransforms(
                "read",
                PTransform.newBuilder()
                    .setUniqueName("Read")
                    .putInputs("input", "flatten.out")
                    .putOutputs("output", "read.out")
                    .setSpec(
                        FunctionSpec.newBuilder()
                            .setUrn(PTransformTranslation.PAR_DO_TRANSFORM_URN)
                            .setPayload(
                                ParDoPayload.newBuilder()
                                    .setDoFn(SdkFunctionSpec.newBuilder().setEnvironmentId("py"))
                                    .build()
                                    .toByteString()))
                    .build())
            .putPcollections("read.out", pc("read.out"))
            .putTransforms(
                "parDo",
                PTransform.newBuilder()
                    .setUniqueName("ParDo")
                    .putInputs("input", "read.out")
                    .putOutputs("output", "parDo.out")
                    .setSpec(
                        FunctionSpec.newBuilder()
                            .setUrn(PTransformTranslation.PAR_DO_TRANSFORM_URN)
                            .setPayload(
                                ParDoPayload.newBuilder()
                                    .setDoFn(
                                        SdkFunctionSpec.newBuilder().setEnvironmentId("py").build())
                                    .build()
                                    .toByteString()))
                    .build())
            .putPcollections("parDo.out", pc("parDo.out"))
            .build();
    FusedPipeline fused =
        GreedyPipelineFuser.fuse(Pipeline.newBuilder().setComponents(components).build());

    assertThat(
        fused.getRunnerExecutedTransforms(),
        containsInAnyOrder(
            PipelineNode.pTransform("impulse", components.getTransformsOrThrow("impulse")),
            PipelineNode.pTransform("flatten", components.getTransformsOrThrow("flatten"))));
    assertThat(
        fused.getFusedStages(),
        contains(
            ExecutableStageMatcher.withInput("flatten.out")
                .withNoOutputs()
                .withTransforms("read", "parDo")));
  }

  /*
   * impulseA -> .out -> read -> .out -> leftParDo -> .out
   *                                  \ -> rightParDo -> .out
   *                                   ------> sideInputParDo -> .out
   *                                        /
   * impulseB -> .out -> side_read -> .out /
   *
   * becomes
   * (impulseA.out) -> read -> (read.out)
   * (read.out) -> leftParDo
   *            \
   *             -> rightParDo
   * (read.out) -> sideInputParDo
   * (impulseB.out) -> side_read
   */
  @Test
  public void sideInputRootsNewStage() {
    Components components =
        Components.newBuilder()
            .putCoders("coder", Coder.newBuilder().build())
            .putCoders("windowCoder", Coder.newBuilder().build())
            .putWindowingStrategies(
                "ws", WindowingStrategy.newBuilder().setWindowCoderId("windowCoder").build())
            .putTransforms(
                "mainImpulse",
                PTransform.newBuilder()
                    .setUniqueName("MainImpulse")
                    .putOutputs("output", "mainImpulse.out")
                    .setSpec(
                        FunctionSpec.newBuilder()
                            .setUrn(PTransformTranslation.IMPULSE_TRANSFORM_URN))
                    .build())
            .putPcollections("mainImpulse.out", pc("mainImpulse.out"))
            .putTransforms(
                "read",
                PTransform.newBuilder()
                    .setUniqueName("Read")
                    .putInputs("input", "mainImpulse.out")
                    .putOutputs("output", "read.out")
                    .setSpec(
                        FunctionSpec.newBuilder()
                            .setUrn(PTransformTranslation.PAR_DO_TRANSFORM_URN)
                            .setPayload(
                                ParDoPayload.newBuilder()
                                    .setDoFn(SdkFunctionSpec.newBuilder().setEnvironmentId("py"))
                                    .build()
                                    .toByteString()))
                    .build())
            .putPcollections("read.out", pc("read.out"))
            .putTransforms(
                "sideImpulse",
                PTransform.newBuilder()
                    .setUniqueName("SideImpulse")
                    .putOutputs("output", "sideImpulse.out")
                    .setSpec(
                        FunctionSpec.newBuilder()
                            .setUrn(PTransformTranslation.IMPULSE_TRANSFORM_URN))
                    .build())
            .putPcollections("sideImpulse.out", pc("sideImpulse.out"))
            .putTransforms(
                "sideRead",
                PTransform.newBuilder()
                    .setUniqueName("SideRead")
                    .putInputs("input", "sideImpulse.out")
                    .putOutputs("output", "sideRead.out")
                    .setSpec(
                        FunctionSpec.newBuilder()
                            .setUrn(PTransformTranslation.PAR_DO_TRANSFORM_URN)
                            .setPayload(
                                ParDoPayload.newBuilder()
                                    .setDoFn(SdkFunctionSpec.newBuilder().setEnvironmentId("py"))
                                    .build()
                                    .toByteString()))
                    .build())
            .putPcollections("sideRead.out", pc("sideRead.out"))
            .putTransforms(
                "leftParDo",
                PTransform.newBuilder()
                    .setUniqueName("LeftParDo")
                    .putInputs("main", "read.out")
                    .putOutputs("output", "leftParDo.out")
                    .setSpec(
                        FunctionSpec.newBuilder()
                            .setUrn(PTransformTranslation.PAR_DO_TRANSFORM_URN)
                            .setPayload(
                                ParDoPayload.newBuilder()
                                    .setDoFn(SdkFunctionSpec.newBuilder().setEnvironmentId("py"))
                                    .build()
                                    .toByteString())
                            .build())
                    .build())
            .putPcollections("leftParDo.out", pc("leftParDo.out"))
            .putTransforms(
                "rightParDo",
                PTransform.newBuilder()
                    .setUniqueName("RightParDo")
                    .putInputs("main", "read.out")
                    .putOutputs("output", "rightParDo.out")
                    .setSpec(
                        FunctionSpec.newBuilder()
                            .setUrn(PTransformTranslation.PAR_DO_TRANSFORM_URN)
                            .setPayload(
                                ParDoPayload.newBuilder()
                                    .setDoFn(SdkFunctionSpec.newBuilder().setEnvironmentId("py"))
                                    .build()
                                    .toByteString())
                            .build())
                    .build())
            .putPcollections("rightParDo.out", pc("rightParDo.out"))
            .putTransforms(
                "sideParDo",
                PTransform.newBuilder()
                    .setUniqueName("SideParDo")
                    .putInputs("main", "read.out")
                    .putInputs("side", "sideRead.out")
                    .putOutputs("output", "sideParDo.out")
                    .setSpec(
                        FunctionSpec.newBuilder()
                            .setUrn(PTransformTranslation.PAR_DO_TRANSFORM_URN)
                            .setPayload(
                                ParDoPayload.newBuilder()
                                    .setDoFn(SdkFunctionSpec.newBuilder().setEnvironmentId("py"))
                                    .putSideInputs("side", SideInput.getDefaultInstance())
                                    .build()
                                    .toByteString())
                            .build())
                    .build())
            .putPcollections("sideParDo.out", pc("sideParDo.out"))
            .putEnvironments("py", Environment.newBuilder().setUrl("py").build())
            .build();

    FusedPipeline fused =
        GreedyPipelineFuser.fuse(Pipeline.newBuilder().setComponents(components).build());

    assertThat(
        fused.getRunnerExecutedTransforms(),
        containsInAnyOrder(
            PipelineNode.pTransform("mainImpulse", components.getTransformsOrThrow("mainImpulse")),
            PipelineNode.pTransform(
                "sideImpulse", components.getTransformsOrThrow("sideImpulse"))));
    assertThat(
        fused.getFusedStages(),
        containsInAnyOrder(
            ExecutableStageMatcher.withInput("mainImpulse.out")
                .withOutputs("read.out")
                .withTransforms("read"),
            ExecutableStageMatcher.withInput("read.out")
                .withNoOutputs()
                .withTransforms("leftParDo", "rightParDo"),
            ExecutableStageMatcher.withInput("read.out")
                .withSideInputs(
                    RunnerApi.ExecutableStagePayload.SideInputId.newBuilder()
                        .setTransformId("sideParDo")
                        .setLocalName("side")
                        .build())
                .withNoOutputs()
                .withTransforms("sideParDo"),
            ExecutableStageMatcher.withInput("sideImpulse.out")
                .withOutputs("sideRead.out")
                .withTransforms("sideRead")));
  }

  /*
   * impulse -> .out -> parDo -> .out -> stateful -> .out
   * becomes
   * (impulse.out) -> parDo -> (parDo.out)
   * (parDo.out) -> stateful
   */
  @Test
  public void statefulParDoRootsStage() {
    // (impulse.out) -> parDo -> (parDo.out)
    // (parDo.out) -> stateful -> stateful.out
    // stateful has a state spec which prevents it from fusing with an upstream ParDo
    PTransform parDoTransform =
        PTransform.newBuilder()
            .setUniqueName("ParDo")
            .putInputs("input", "impulse.out")
            .putOutputs("output", "parDo.out")
            .setSpec(
                FunctionSpec.newBuilder()
                    .setUrn(PTransformTranslation.PAR_DO_TRANSFORM_URN)
                    .setPayload(
                        ParDoPayload.newBuilder()
                            .setDoFn(SdkFunctionSpec.newBuilder().setEnvironmentId("common"))
                            .build()
                            .toByteString()))
            .build();
    PTransform statefulTransform =
        PTransform.newBuilder()
            .setUniqueName("StatefulParDo")
            .putInputs("input", "parDo.out")
            .putOutputs("output", "stateful.out")
            .setSpec(
                FunctionSpec.newBuilder()
                    .setUrn(PTransformTranslation.PAR_DO_TRANSFORM_URN)
                    .setPayload(
                        ParDoPayload.newBuilder()
                            .setDoFn(SdkFunctionSpec.newBuilder().setEnvironmentId("common"))
                            .putStateSpecs("state", StateSpec.getDefaultInstance())
                            .build()
                            .toByteString()))
            .build();

    Components components =
        partialComponents
            .toBuilder()
            .putTransforms("parDo", parDoTransform)
            .putPcollections("parDo.out", pc("parDo.out"))
            .putTransforms("stateful", statefulTransform)
            .putPcollections("stateful.out", pc("stateful.out"))
            .putEnvironments("common", Environment.newBuilder().setUrl("common").build())
            .build();
    FusedPipeline fused =
        GreedyPipelineFuser.fuse(Pipeline.newBuilder().setComponents(components).build());

    assertThat(
        fused.getRunnerExecutedTransforms(),
        containsInAnyOrder(
            PipelineNode.pTransform("impulse", components.getTransformsOrThrow("impulse"))));
    assertThat(
        fused.getFusedStages(),
        containsInAnyOrder(
            ExecutableStageMatcher.withInput("impulse.out")
                .withOutputs("parDo.out")
                .withTransforms("parDo"),
            ExecutableStageMatcher.withInput("parDo.out")
                .withNoOutputs()
                .withTransforms("stateful")));
  }

  /*
   * impulse -> .out -> parDo -> .out -> timer -> .out
   * becomes
   * (impulse.out) -> parDo -> (parDo.out)
   * (parDo.out) -> timer
   */
  @Test
  public void parDoWithTimerRootsStage() {
    // (impulse.out) -> parDo -> (parDo.out)
    // (parDo.out) -> timer -> timer.out
    // timer has a timer spec which prevents it from fusing with an upstream ParDo
    PTransform parDoTransform =
        PTransform.newBuilder()
            .setUniqueName("ParDo")
            .putInputs("input", "impulse.out")
            .putOutputs("output", "parDo.out")
            .setSpec(
                FunctionSpec.newBuilder()
                    .setUrn(PTransformTranslation.PAR_DO_TRANSFORM_URN)
                    .setPayload(
                        ParDoPayload.newBuilder()
                            .setDoFn(SdkFunctionSpec.newBuilder().setEnvironmentId("common"))
                            .build()
                            .toByteString()))
            .build();
    PTransform timerTransform =
        PTransform.newBuilder()
            .setUniqueName("TimerParDo")
            .putInputs("input", "parDo.out")
            .putInputs("timer", "timer.out")
            .putOutputs("timer", "timer.out")
            .putOutputs("output", "output.out")
            .setSpec(
                FunctionSpec.newBuilder()
                    .setUrn(PTransformTranslation.PAR_DO_TRANSFORM_URN)
                    .setPayload(
                        ParDoPayload.newBuilder()
                            .setDoFn(SdkFunctionSpec.newBuilder().setEnvironmentId("common"))
                            .putTimerSpecs("timer", TimerSpec.getDefaultInstance())
                            .build()
                            .toByteString()))
            .build();

    Components components =
        partialComponents
            .toBuilder()
            .putTransforms("parDo", parDoTransform)
            .putPcollections("parDo.out", pc("parDo.out"))
            .putTransforms("timer", timerTransform)
            .putPcollections("timer.out", pc("timer.out"))
            .putPcollections("output.out", pc("output.out"))
            .putEnvironments("common", Environment.newBuilder().setUrl("common").build())
            .build();

    FusedPipeline fused =
        GreedyPipelineFuser.fuse(Pipeline.newBuilder().setComponents(components).build());

    assertThat(
        fused.getRunnerExecutedTransforms(),
        containsInAnyOrder(
            PipelineNode.pTransform("impulse", components.getTransformsOrThrow("impulse"))));
    assertThat(
        fused.getFusedStages(),
        containsInAnyOrder(
            ExecutableStageMatcher.withInput("impulse.out")
                .withOutputs("parDo.out")
                .withTransforms("parDo"),
            ExecutableStageMatcher.withInput("parDo.out").withNoOutputs().withTransforms("timer")));
  }

  /*
   * impulse -> .out -> ( read -> .out --> goTransform -> .out )
   *                                    \
   *                                     -> pyTransform -> .out )
   * becomes (impulse.out) -> read -> (read.out)
   *         (read.out) -> goTransform
   *         (read.out) -> pyTransform
   */
  @Test
  public void compositesIgnored() {
    Components components =
        partialComponents
            .toBuilder()
            .putTransforms(
                "read",
                PTransform.newBuilder()
                    .setUniqueName("Read")
                    .putInputs("input", "impulse.out")
                    .putOutputs("output", "read.out")
                    .setSpec(
                        FunctionSpec.newBuilder()
                            .setUrn(PTransformTranslation.PAR_DO_TRANSFORM_URN)
                            .setPayload(
                                ParDoPayload.newBuilder()
                                    .setDoFn(SdkFunctionSpec.newBuilder().setEnvironmentId("py"))
                                    .build()
                                    .toByteString()))
                    .build())
            .putPcollections("read.out", pc("read.out"))
            .putTransforms(
                "goTransform",
                PTransform.newBuilder()
                    .setUniqueName("GoTransform")
                    .putInputs("input", "read.out")
                    .putOutputs("output", "go.out")
                    .setSpec(
                        FunctionSpec.newBuilder()
                            .setUrn(PTransformTranslation.PAR_DO_TRANSFORM_URN)
                            .setPayload(
                                ParDoPayload.newBuilder()
                                    .setDoFn(SdkFunctionSpec.newBuilder().setEnvironmentId("go"))
                                    .build()
                                    .toByteString()))
                    .build())
            .putPcollections("go.out", pc("go.out"))
            .putTransforms(
                "pyTransform",
                PTransform.newBuilder()
                    .setUniqueName("PyTransform")
                    .putInputs("input", "read.out")
                    .putOutputs("output", "py.out")
                    .setSpec(
                        FunctionSpec.newBuilder()
                            .setUrn(PTransformTranslation.ASSIGN_WINDOWS_TRANSFORM_URN)
                            .setPayload(
                                WindowIntoPayload.newBuilder()
                                    .setWindowFn(
                                        SdkFunctionSpec.newBuilder().setEnvironmentId("py"))
                                    .build()
                                    .toByteString()))
                    .build())
            .putPcollections("py.out", pc("py.out"))
            .putTransforms(
                "compositeMultiLang",
                PTransform.newBuilder()
                    .setUniqueName("CompositeMultiLang")
                    .putInputs("input", "impulse.out")
                    .putOutputs("pyOut", "py.out")
                    .putOutputs("goOut", "go.out")
                    .addSubtransforms("read")
                    .addSubtransforms("goTransform")
                    .addSubtransforms("pyTransform")
                    .build())
            .build();
    FusedPipeline fused =
        GreedyPipelineFuser.fuse(
            Pipeline.newBuilder()
                .addRootTransformIds("impulse")
                .addRootTransformIds("compositeMultiLang")
                .setComponents(components)
                .build());

    // Impulse is the runner transform
    assertThat(fused.getRunnerExecutedTransforms(), hasSize(1));
    assertThat(fused.getFusedStages(), hasSize(3));
    assertThat(
        fused.getFusedStages(),
        containsInAnyOrder(
            ExecutableStageMatcher.withInput("impulse.out")
                .withOutputs("read.out")
                .withTransforms("read"),
            ExecutableStageMatcher.withInput("read.out")
                .withNoOutputs()
                .withTransforms("pyTransform"),
            ExecutableStageMatcher.withInput("read.out")
                .withNoOutputs()
                .withTransforms("goTransform")));
  }

  @Test
  public void sanitizedTransforms() throws Exception {

    PCollection flattenOutput = pc("flatten.out");
    PCollection read1Output = pc("read1.out");
    PCollection read2Output = pc("read2.out");
    PCollection impulse1Output = pc("impulse1.out");
    PCollection impulse2Output = pc("impulse2.out");
    PTransform flattenTransform =
        PTransform.newBuilder()
            .setUniqueName("Flatten")
            .putInputs(read1Output.getUniqueName(), read1Output.getUniqueName())
            .putInputs(read2Output.getUniqueName(), read2Output.getUniqueName())
            .putOutputs(flattenOutput.getUniqueName(), flattenOutput.getUniqueName())
            .setSpec(
                FunctionSpec.newBuilder()
                    .setUrn(PTransformTranslation.FLATTEN_TRANSFORM_URN)
                    .setPayload(
                        WindowIntoPayload.newBuilder()
                            .setWindowFn(SdkFunctionSpec.newBuilder().setEnvironmentId("py"))
                            .build()
                            .toByteString()))
            .build();

    PTransform read1Transform =
        PTransform.newBuilder()
            .setUniqueName("read1")
            .putInputs(impulse1Output.getUniqueName(), impulse1Output.getUniqueName())
            .putOutputs(read1Output.getUniqueName(), read1Output.getUniqueName())
            .setSpec(
                FunctionSpec.newBuilder()
                    .setUrn(PTransformTranslation.PAR_DO_TRANSFORM_URN)
                    .setPayload(
                        WindowIntoPayload.newBuilder()
                            .setWindowFn(SdkFunctionSpec.newBuilder().setEnvironmentId("py"))
                            .build()
                            .toByteString()))
            .build();
    PTransform read2Transform =
        PTransform.newBuilder()
            .setUniqueName("read2")
            .putInputs(impulse2Output.getUniqueName(), impulse2Output.getUniqueName())
            .putOutputs(read2Output.getUniqueName(), read2Output.getUniqueName())
            .setSpec(
                FunctionSpec.newBuilder()
                    .setUrn(PTransformTranslation.PAR_DO_TRANSFORM_URN)
                    .setPayload(
                        WindowIntoPayload.newBuilder()
                            .setWindowFn(SdkFunctionSpec.newBuilder().setEnvironmentId("py"))
                            .build()
                            .toByteString()))
            .build();

    PTransform impulse1Transform =
        PTransform.newBuilder()
            .setUniqueName("impulse1")
            .putOutputs(impulse1Output.getUniqueName(), impulse1Output.getUniqueName())
            .setSpec(
                FunctionSpec.newBuilder()
                    .setUrn(PTransformTranslation.IMPULSE_TRANSFORM_URN)
                    .setPayload(
                        WindowIntoPayload.newBuilder()
                            .setWindowFn(SdkFunctionSpec.newBuilder().setEnvironmentId("py"))
                            .build()
                            .toByteString()))
            .build();
    PTransform impulse2Transform =
        PTransform.newBuilder()
            .setUniqueName("impulse2")
            .putOutputs(impulse2Output.getUniqueName(), impulse2Output.getUniqueName())
            .setSpec(
                FunctionSpec.newBuilder()
                    .setUrn(PTransformTranslation.IMPULSE_TRANSFORM_URN)
                    .setPayload(
                        WindowIntoPayload.newBuilder()
                            .setWindowFn(SdkFunctionSpec.newBuilder().setEnvironmentId("py"))
                            .build()
                            .toByteString()))
            .build();
    Pipeline impulse =
        Pipeline.newBuilder()
            .addRootTransformIds(impulse1Transform.getUniqueName())
            .addRootTransformIds(impulse2Transform.getUniqueName())
            .addRootTransformIds(flattenTransform.getUniqueName())
            .setComponents(
                Components.newBuilder()
                    .putCoders("coder", Coder.newBuilder().build())
                    .putCoders("windowCoder", Coder.newBuilder().build())
                    .putWindowingStrategies(
                        "ws",
                        WindowingStrategy.newBuilder().setWindowCoderId("windowCoder").build())
                    .putEnvironments("py", Environment.newBuilder().setUrl("py").build())
                    .putPcollections(flattenOutput.getUniqueName(), flattenOutput)
                    .putTransforms(flattenTransform.getUniqueName(), flattenTransform)
                    .putPcollections(read1Output.getUniqueName(), read1Output)
                    .putTransforms(read1Transform.getUniqueName(), read1Transform)
                    .putPcollections(read2Output.getUniqueName(), read2Output)
                    .putTransforms(read2Transform.getUniqueName(), read2Transform)
                    .putPcollections(impulse1Output.getUniqueName(), impulse1Output)
                    .putTransforms(impulse1Transform.getUniqueName(), impulse1Transform)
                    .putPcollections(impulse2Output.getUniqueName(), impulse2Output)
                    .putTransforms(impulse2Transform.getUniqueName(), impulse2Transform)
                    .build())
            .build();
    FusedPipeline fused = GreedyPipelineFuser.fuse(impulse);

    assertThat(fused.getRunnerExecutedTransforms(), hasSize(2));
    assertThat(fused.getFusedStages(), hasSize(2));

    assertThat(
        fused.getFusedStages(),
        containsInAnyOrder(
            ExecutableStageMatcher.withInput(impulse1Output.getUniqueName())
                .withTransforms(flattenTransform.getUniqueName(), read1Transform.getUniqueName()),
            ExecutableStageMatcher.withInput(impulse2Output.getUniqueName())
                .withTransforms(flattenTransform.getUniqueName(), read2Transform.getUniqueName())));
    assertThat(
        fused
            .getFusedStages()
            .stream()
            .flatMap(
                s ->
                    s.getComponents()
                        .getTransformsOrThrow(flattenTransform.getUniqueName())
                        .getInputsMap()
                        .values()
                        .stream())
            .collect(Collectors.toList()),
        containsInAnyOrder(read1Output.getUniqueName(), read2Output.getUniqueName()));
  }
}
