7.8 KiB · text History cac71de
defmodule GitGud.Workflows.MatrixTest do
  @moduledoc """
  Matrix expansion, checked against the examples in GitHub's own
  workflow-syntax documentation — `include` and `exclude` are the parts
  people actually get wrong, and "it seemed reasonable" is not a
  specification.
  """
  use ExUnit.Case, async: true

  alias GitGud.Workflows.Matrix

  defp strategy(matrix), do: %{"matrix" => matrix}

  describe "expand/1" do
    test "no strategy is one leg with nothing in it" do
      assert Matrix.expand(nil) == [%{}]
      assert Matrix.expand(%{}) == [%{}]
      assert Matrix.expand(%{"fail-fast" => false}) == [%{}]
    end

    test "one axis is one job per value" do
      assert Matrix.expand(strategy(%{"arch" => ["amd64", "arm64"]})) == [
               %{"arch" => "amd64"},
               %{"arch" => "arm64"}
             ]
    end

    test "two axes are their product" do
      combinations =
        Matrix.expand(strategy(%{"arch" => ["amd64", "arm64"], "otp" => ["27", "28"]}))

      assert length(combinations) == 4

      assert %{"arch" => "amd64", "otp" => "27"} in combinations
      assert %{"arch" => "arm64", "otp" => "28"} in combinations
    end

    test "a scalar value is a single-value axis" do
      assert Matrix.expand(strategy(%{"arch" => "arm64"})) == [%{"arch" => "arm64"}]
    end

    test "expansion is stable — map order must not decide job order" do
      matrix = %{"arch" => ["amd64", "arm64"], "otp" => ["27", "28"], "os" => ["a", "b"]}

      assert Matrix.expand(strategy(matrix)) == Matrix.expand(strategy(matrix))
    end
  end

  describe "exclude" do
    test "drops combinations matching every key it names" do
      combinations =
        Matrix.expand(
          strategy(%{
            "arch" => ["amd64", "arm64"],
            "otp" => ["27", "28"],
            "exclude" => [%{"arch" => "arm64", "otp" => "27"}]
          })
        )

      assert length(combinations) == 3
      refute %{"arch" => "arm64", "otp" => "27"} in combinations
    end

    test "a partial exclude drops every combination it matches" do
      combinations =
        Matrix.expand(
          strategy(%{
            "arch" => ["amd64", "arm64"],
            "otp" => ["27", "28"],
            "exclude" => [%{"arch" => "arm64"}]
          })
        )

      assert combinations == [
               %{"arch" => "amd64", "otp" => "27"},
               %{"arch" => "amd64", "otp" => "28"}
             ]
    end
  end

  describe "include" do
    test "adds keys to the combinations it matches rather than appending a job" do
      combinations =
        Matrix.expand(
          strategy(%{
            "arch" => ["amd64", "arm64"],
            "include" => [%{"arch" => "arm64", "coverage" => true}]
          })
        )

      # Still two jobs — the arm64 one just gained a key.
      assert combinations == [
               %{"arch" => "amd64"},
               %{"arch" => "arm64", "coverage" => true}
             ]
    end

    test "an entry matching nothing becomes a combination of its own" do
      combinations =
        Matrix.expand(
          strategy(%{
            "arch" => ["amd64", "arm64"],
            "include" => [%{"arch" => "riscv64"}]
          })
        )

      assert combinations == [
               %{"arch" => "amd64"},
               %{"arch" => "arm64"},
               %{"arch" => "riscv64"}
             ]
    end

    test "an entry with no overlapping keys applies to every combination" do
      combinations =
        Matrix.expand(
          strategy(%{
            "arch" => ["amd64", "arm64"],
            "include" => [%{"otp" => "28"}]
          })
        )

      assert combinations == [
               %{"arch" => "amd64", "otp" => "28"},
               %{"arch" => "arm64", "otp" => "28"}
             ]
    end

    test "include runs after exclude, so it can add a combination back" do
      combinations =
        Matrix.expand(
          strategy(%{
            "arch" => ["amd64", "arm64"],
            "exclude" => [%{"arch" => "arm64"}],
            "include" => [%{"arch" => "arm64", "experimental" => true}]
          })
        )

      assert combinations == [
               %{"arch" => "amd64"},
               %{"arch" => "arm64", "experimental" => true}
             ]
    end

    test "an include-only matrix is just its entries" do
      combinations =
        Matrix.expand(
          strategy(%{
            "include" => [
              %{"arch" => "amd64", "runner" => "big"},
              %{"arch" => "arm64", "runner" => "small"}
            ]
          })
        )

      assert combinations == [
               %{"arch" => "amd64", "runner" => "big"},
               %{"arch" => "arm64", "runner" => "small"}
             ]
    end
  end

  describe "substitute/2" do
    test "resolves matrix references in a runs-on" do
      assert Matrix.substitute("${{ matrix.arch }}", %{"arch" => "arm64"}) == "arm64"
      assert Matrix.substitute("${{matrix.arch}}", %{"arch" => "arm64"}) == "arm64"
      assert Matrix.substitute("linux-${{ matrix.arch }}", %{"arch" => "arm64"}) == "linux-arm64"
    end

    test "resolves inside a list, which is how an architecture is usually selected" do
      assert Matrix.substitute(["self-hosted", "${{ matrix.arch }}"], %{"arch" => "arm64"}) ==
               ["self-hosted", "arm64"]
    end

    test "an unknown key resolves to empty, as it does in Actions" do
      assert Matrix.substitute("${{ matrix.nope }}", %{"arch" => "arm64"}) == ""
    end

    test "leaves other expressions alone — the runner evaluates those" do
      assert Matrix.substitute("${{ github.sha }}", %{"arch" => "arm64"}) == "${{ github.sha }}"
    end

    test "non-strings pass through" do
      assert Matrix.substitute(42, %{}) == 42
      assert Matrix.substitute(nil, %{}) == nil
    end
  end

  describe "labels/1" do
    test "a scalar and a one-element list are the same thing" do
      assert Matrix.labels("ubuntu-latest") == ["ubuntu-latest"]
      assert Matrix.labels(["ubuntu-latest"]) == ["ubuntu-latest"]
    end

    test "keeps every label in a list — all of them must be satisfied" do
      assert Matrix.labels(["self-hosted", "arm64"]) == ["self-hosted", "arm64"]
    end

    test "drops blanks and duplicates" do
      assert Matrix.labels(["self-hosted", "", "self-hosted"]) == ["self-hosted"]
    end
  end

  describe "pin_definition/2" do
    test "pins the matrix to this leg so the runner expands it to exactly one job" do
      definition = %{
        "runs-on" => "self-hosted",
        "strategy" => %{"matrix" => %{"arch" => ["amd64", "arm64"]}, "fail-fast" => false},
        "steps" => [%{"run" => "echo ${{ matrix.arch }}"}]
      }

      pinned = Matrix.pin_definition(definition, %{"arch" => "arm64"})

      # One value, so act fans out to one job and the `matrix` context
      # still resolves for the step.
      # `fail-fast` rides along: the forge reads it back off the stored
      # definition, and this is the only copy that survives.
      assert pinned["strategy"] == %{"matrix" => %{"arch" => ["arm64"]}, "fail-fast" => false}
      assert pinned["steps"] == definition["steps"]
    end

    test "a job with no matrix loses its strategy entirely" do
      definition = %{"strategy" => %{"fail-fast" => false}, "runs-on" => "x"}

      assert Matrix.pin_definition(definition, %{}) == %{"runs-on" => "x"}
    end
  end

  describe "suffix/1 and fail_fast?/1" do
    test "names a leg the way Actions does" do
      assert Matrix.suffix(%{}) == nil
      assert Matrix.suffix(%{"arch" => "arm64"}) == "arm64"
      assert Matrix.suffix(%{"arch" => "arm64", "otp" => "28"}) == "arm64, 28"
    end

    test "fail-fast defaults on, and only an explicit false turns it off" do
      assert Matrix.fail_fast?(nil)
      assert Matrix.fail_fast?(%{"matrix" => %{}})
      refute Matrix.fail_fast?(%{"fail-fast" => false})
      refute Matrix.fail_fast?(%{"fail-fast" => "false"})
    end
  end
end