Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix create balance when incomplete data #315

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions lib/teiserver/battle/libs/balance_lib.ex
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ defmodule Teiserver.Battle.BalanceLib do

def create_balance(groups, team_count, opts) do
start_time = System.system_time(:microsecond)
groups = standardise_groups(groups)

# We perform all our group calculations here and assign each group
# an ID that's used purely for this run of balance
Expand Down Expand Up @@ -154,6 +155,23 @@ defmodule Teiserver.Battle.BalanceLib do
|> Map.put(:time_taken, System.system_time(:microsecond) - start_time)
end

@doc """
Sometimes groups have missing data so we need to refetch it.
If we go through balancer_server then all the required data should be there
"""
def standardise_groups(groups) do
groups
|> Enum.map(fn group ->
# Iterate over our map
Map.new(group, fn {user_id, value} ->
cond do
is_number(value) -> {user_id, get_user_rating_rank_old(user_id, value)}
true -> {user_id, value}
end
end)
end)
end
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I understand this bit correctly, when the value in the map is not a user struct, you want to fetch the corresponding user and replace the id with that user right?

In that case there's a much simpler way: Enum.map

    Enum.map(groups, fn group ->
      group
      |> Enum.map(fn {user_id, value} ->
        cond do
          is_number(value) -> {user_id, get_user_rating_rank_old(user_id, value)}
          true -> {user_id, value}
        end
      end)
      |> Enum.into(%{})
    end)

or, if you prefer using guards, you can also do the following

    Enum.map(groups, fn group ->
      group
      |> Enum.map(fn
        {user_id, value} when is_number(value) ->
          {user_id, get_user_rating_rank_old(user_id, value)}

        {user_id, value} ->
          {user_id, value}
      end)
      |> Enum.into(%{})
    end)

The advantage of Enum.map is that the intent is clearer, you are applying some sort of transformation on every element. In the case of a map it's a tuple of {key, value}.
When using map_reduce, it's a lot more complicated to figure out what's going on. The fact that you had to discard one of the return value is a code smell as well.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had a feeling there was a better way to do it. Before you reviewed this, I had already posted a question on Elixir forums: https://elixirforum.com/t/iterate-through-map-of-dynamic-keys-and-update-their-values/64061/3
I'm liking the idea of Map.new


# Removes various keys we don't care about
defp cleanup_result(result) do
Map.take(
Expand Down Expand Up @@ -582,6 +600,14 @@ defmodule Teiserver.Battle.BalanceLib do
%{rating: rating, rank: rank, name: name}
end

@doc """
This is used by some screens to calculate a theoretical balance based on old ratings
"""
def get_user_rating_rank_old(userid, rating_value) do
%{rank: rank, name: name} = Account.get_user_by_id(userid)
%{rating: rating_value, rank: rank, name: name}
end

defp fuzz_rating(rating, multiplier) do
# Generate something between -1 and 1
modifier = 1 - :rand.uniform() * 2
Expand Down
72 changes: 72 additions & 0 deletions test/teiserver/battle/balance_lib_internal_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
defmodule Teiserver.Battle.BalanceLibInternalTest do
@moduledoc """
Can run all balance tests via
mix test --only balance_test
"""
use Teiserver.DataCase, async: true
@moduletag :balance_test
Copy link
Contributor

@geekingfrog geekingfrog Jun 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not convinced by this tag. Test tags should be used for logical grouping only. Like tag: cluster when you have several tests spanning multiple files testing one feature.
If you only want to test one file, you can always run mix test path/to/the/file

I think that tag could make sense for all balance related tests. But in this case, let's make another PR that tags all the relevant tests at once. And the tag should only be :balance since in this context, it's clear this is about testing.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The other balance tests are tagged already though. So it's hitting multiple files.

alias Teiserver.Battle.BalanceLib

test "Able to standardise groups with incomplete data" do

Check failure on line 10 in test/teiserver/battle/balance_lib_internal_test.exs

View workflow job for this annotation

GitHub Actions / Build and test

test Able to standardise groups with incomplete data (Teiserver.Battle.BalanceLibInternalTest)
[user1, user2, user3, user4, user5] = create_test_users()

groups = [
%{user1.id => 19, user2.id => 20},
%{user3.id => 18},
%{user4.id => 15},
%{user5.id => 11}
]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not a fan at all of the use of mocks. It forces you to have a deep knowledge of the internals of the stuff you're testing. You recognise that with the comment a bit bellow:

loser_picks algo will hit the databases so let's just test with split_one_chevs

It's also a departure from the other tests elsewhere.

For these reason, I'd rather have something that creates the mock data in the DB, and then exercise the code under test.

Using DataCase, async: true, I propose the following:

    [user1, user2, user3, user4, user5] =
      Enum.map(1..5, fn k ->
        Teiserver.TeiserverTestLib.new_user("User_#{k}")
      end)

    groups = [
      %{user1.id => 19, user2.id => 20},
      %{user3.id => 18},
      %{user4.id => 15},
      %{user5.id => 11}
    ]

    fixed_groups = BalanceLib.standardise_groups(groups)

    assert fixed_groups == [
             %{
               user1.id => %{name: "User_1", rank: 0, rating: 19},
               user2.id => %{name: "User_2", rank: 0, rating: 20}
             },
             %{user3.id => %{name: "User_3", rank: 0, rating: 18}},
             %{user4.id => %{name: "User_4", rank: 0, rating: 15}},
             %{user5.id => %{name: "User_5", rank: 0, rating: 11}}
           ]

and similar for the other test. You could even consider pulling the creation of users in a setup function for the module.

Let me know if you have any question over this code.

Copy link
Member Author

@jauggy jauggy Jun 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just tested this and any db changes are persisted between tests which means I cannot call TeiserverTestLib.new_user at beginning of each test. That function will create a user with a random name if it finds the user already exists.

It's really weird. If I call the test multiple times it will eventually fail.


fixed_groups = BalanceLib.standardise_groups(groups)

assert fixed_groups == [
%{
user1.id => %{name: "User_1", rank: 0, rating: 19},
user2.id => %{name: "User_2", rank: 0, rating: 20}
},
%{user3.id => %{name: "User_3", rank: 0, rating: 18}},
%{user4.id => %{name: "User_4", rank: 0, rating: 15}},
%{user5.id => %{name: "User_5", rank: 0, rating: 11}}
]

# loser_picks algo will hit the databases so let's just test with split_one_chevs
result = BalanceLib.create_balance(fixed_groups, 2, algorithm: "split_one_chevs")
assert result != nil
end

test "Handle groups with incomplete data in create_balance loser_pics" do
[user1, user2, user3, user4, user5] = create_test_users()

groups = [
%{user1.id => 19, user2.id => 20},
%{user3.id => 18},
%{user4.id => 15},
%{user5.id => 11}
]

# loser_picks algo will hit the databases so let's just test with split_one_chevs
result = BalanceLib.create_balance(groups, 2, algorithm: "loser_picks")
assert result != nil
end

test "Handle groups with incomplete data in create_balance split_one_chevs" do
[user1, user2, user3, user4, user5] = create_test_users()

groups = [
%{user1.id => 19, user2.id => 20},
%{user3.id => 18},
%{user4.id => 15},
%{user5.id => 11}
]

# loser_picks algo will hit the databases so let's just test with split_one_chevs
result = BalanceLib.create_balance(groups, 2, algorithm: "split_one_chevs")
assert result != nil
end

defp create_test_users do
Enum.map(1..5, fn k ->
Teiserver.TeiserverTestLib.new_user("User_#{k}")
end)
end
end
2 changes: 1 addition & 1 deletion test/teiserver/battle/split_one_chevs_internal_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ defmodule Teiserver.Battle.SplitOneChevsInternalTest do
Can run tests in this file only by
mix test test/teiserver/battle/split_one_chevs_internal_test.exs
"""
use Teiserver.DataCase, async: true
use ExUnit.Case
@moduletag :balance_test
alias Teiserver.Battle.Balance.SplitOneChevs

Expand Down
2 changes: 1 addition & 1 deletion test/teiserver/battle/split_one_chevs_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ defmodule Teiserver.Battle.SplitOneChevsTest do
Can run tests in this file only by
mix test test/teiserver/battle/split_one_chevs_test.exs
"""
use Teiserver.DataCase, async: true
use ExUnit.Case
@moduletag :balance_test
alias Teiserver.Battle.BalanceLib

Expand Down
Loading