oneshot
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
defmodule Rsh26pool.JSTTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Rsh26pool.JST
|
||||
|
||||
describe "parse_local/1" do
|
||||
test "interprets the input as wall-clock JST and returns UTC (-9h)" do
|
||||
assert JST.parse_local("2026-07-04T18:00") == {:ok, ~U[2026-07-04 09:00:00Z]}
|
||||
end
|
||||
|
||||
test "accepts a value that already carries seconds" do
|
||||
assert JST.parse_local("2026-07-04T18:00:30") == {:ok, ~U[2026-07-04 09:00:30Z]}
|
||||
end
|
||||
|
||||
test "shifts across the date boundary when JST wall time is early morning" do
|
||||
# 2026-07-04 05:00 JST is the previous UTC day at 20:00.
|
||||
assert JST.parse_local("2026-07-04T05:00") == {:ok, ~U[2026-07-03 20:00:00Z]}
|
||||
end
|
||||
|
||||
test "returns :error on unparseable text" do
|
||||
assert JST.parse_local("garbage") == :error
|
||||
end
|
||||
|
||||
test "returns :error for non-binary input" do
|
||||
assert JST.parse_local(nil) == :error
|
||||
assert JST.parse_local(1234) == :error
|
||||
end
|
||||
end
|
||||
|
||||
describe "to_local/1 and format/1" do
|
||||
test "to_local shifts a stored UTC datetime forward by 9 hours" do
|
||||
assert JST.to_local(~U[2026-07-04 09:00:00Z]) == ~U[2026-07-04 18:00:00Z]
|
||||
end
|
||||
|
||||
test "format renders the stored UTC datetime as JST wall time" do
|
||||
assert JST.format(~U[2026-07-04 09:00:00Z]) == "2026/07/04 18:00"
|
||||
end
|
||||
|
||||
test "format(nil) is the empty string" do
|
||||
assert JST.format(nil) == ""
|
||||
end
|
||||
end
|
||||
|
||||
describe "parse_local |> format round-trip" do
|
||||
test "is the identity on the wall clock at minute precision" do
|
||||
wall_times = [
|
||||
"2026-07-04T18:00",
|
||||
"2026-01-01T00:00",
|
||||
"2026-12-31T23:59",
|
||||
"2026-03-15T09:05",
|
||||
"2026-07-04T05:00"
|
||||
]
|
||||
|
||||
for wall <- wall_times do
|
||||
assert {:ok, utc} = JST.parse_local(wall)
|
||||
expected = wall |> String.replace("-", "/") |> String.replace("T", " ")
|
||||
assert JST.format(utc) == expected
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,92 @@
|
||||
defmodule Rsh26pool.Voting.HungarianTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Rsh26pool.Voting.Hungarian
|
||||
|
||||
describe "min_cost_assignment/1" do
|
||||
test "empty matrix yields no cost and no assignment" do
|
||||
assert Hungarian.min_cost_assignment([]) == {0, []}
|
||||
end
|
||||
|
||||
test "raises when there are more rows than columns" do
|
||||
assert_raise ArgumentError, fn ->
|
||||
Hungarian.min_cost_assignment([[1, 2], [3, 4], [5, 6]])
|
||||
end
|
||||
end
|
||||
|
||||
test "square matrix matches the brute-force optimum" do
|
||||
cost = [[4, 1, 3], [2, 0, 5], [3, 2, 2]]
|
||||
{total, assignment} = Hungarian.min_cost_assignment(cost)
|
||||
|
||||
{brute_total, _} = brute_force(cost)
|
||||
assert total == brute_total
|
||||
assert valid_assignment?(assignment, 3, 3)
|
||||
assert picked_sum(cost, assignment) == total
|
||||
end
|
||||
|
||||
test "rectangular matrix (rows < cols) matches the brute-force optimum" do
|
||||
cost = [[3, 1, 2, 4], [4, 2, 1, 0]]
|
||||
{total, assignment} = Hungarian.min_cost_assignment(cost)
|
||||
|
||||
{brute_total, _} = brute_force(cost)
|
||||
assert total == brute_total
|
||||
assert valid_assignment?(assignment, 2, 4)
|
||||
assert picked_sum(cost, assignment) == total
|
||||
end
|
||||
|
||||
test "matches the brute-force optimum across many random small matrices" do
|
||||
# Deterministic: fixed seed, and the failing matrix is printed for repro.
|
||||
:rand.seed(:exsss, {13, 57, 911})
|
||||
|
||||
for _ <- 1..250 do
|
||||
n = Enum.random(1..4)
|
||||
m = Enum.random(n..5)
|
||||
cost = for _ <- 1..n, do: for(_ <- 1..m, do: Enum.random(0..9))
|
||||
|
||||
{total, assignment} = Hungarian.min_cost_assignment(cost)
|
||||
{brute_total, _} = brute_force(cost)
|
||||
|
||||
assert total == brute_total,
|
||||
"cost=#{inspect(cost)} gave total #{total}, brute force found #{brute_total}"
|
||||
|
||||
assert valid_assignment?(assignment, n, m),
|
||||
"cost=#{inspect(cost)} gave invalid assignment #{inspect(assignment)}"
|
||||
|
||||
assert picked_sum(cost, assignment) == total,
|
||||
"cost=#{inspect(cost)}: reported total #{total} != summed picks for #{inspect(assignment)}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# An assignment must be an injective mapping row -> column: length n, every
|
||||
# value a distinct column in range.
|
||||
defp valid_assignment?(assignment, n, m) do
|
||||
length(assignment) == n and
|
||||
Enum.all?(assignment, &(&1 >= 0 and &1 < m)) and
|
||||
length(Enum.uniq(assignment)) == n
|
||||
end
|
||||
|
||||
defp picked_sum(cost, assignment) do
|
||||
assignment
|
||||
|> Enum.with_index()
|
||||
|> Enum.reduce(0, fn {col, row}, acc -> acc + (cost |> Enum.at(row) |> Enum.at(col)) end)
|
||||
end
|
||||
|
||||
# Exhaustive optimum over every injective row->column mapping.
|
||||
defp brute_force(cost) do
|
||||
n = length(cost)
|
||||
m = length(hd(cost))
|
||||
cols = Enum.to_list(0..(m - 1))
|
||||
|
||||
cols
|
||||
|> injective_maps(n)
|
||||
|> Enum.map(fn assignment -> {picked_sum(cost, assignment), assignment} end)
|
||||
|> Enum.min_by(&elem(&1, 0))
|
||||
end
|
||||
|
||||
defp injective_maps(_cols, 0), do: [[]]
|
||||
|
||||
defp injective_maps(cols, k) do
|
||||
for col <- cols, rest <- injective_maps(cols -- [col], k - 1), do: [col | rest]
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,425 @@
|
||||
defmodule Rsh26pool.VotingTest do
|
||||
use Rsh26pool.DataCase, async: true
|
||||
|
||||
alias Rsh26pool.Voting
|
||||
|
||||
defp valid_room_attrs(overrides) do
|
||||
Map.merge(
|
||||
%{
|
||||
"name" => "テストルーム",
|
||||
"votes_per_user" => "3",
|
||||
"grouping_mode" => "false",
|
||||
"leader_threshold" => "2",
|
||||
"closes_at_local" => ""
|
||||
},
|
||||
overrides
|
||||
)
|
||||
end
|
||||
|
||||
defp new_room(overrides \\ %{}) do
|
||||
{:ok, result} = Voting.create_room(valid_room_attrs(overrides))
|
||||
result
|
||||
end
|
||||
|
||||
defp seconds_ago(n) do
|
||||
DateTime.utc_now() |> DateTime.add(-n, :second) |> DateTime.truncate(:second)
|
||||
end
|
||||
|
||||
defp set_closes_at(room, dt) do
|
||||
{:ok, room} = room |> Ecto.Changeset.change(closes_at: dt) |> Repo.update()
|
||||
room
|
||||
end
|
||||
|
||||
describe "create_room/1" do
|
||||
test "creates an open room with a 6-digit number and its owner" do
|
||||
assert {:ok, %{room: room, owner: owner}} =
|
||||
Voting.create_room(
|
||||
valid_room_attrs(%{
|
||||
"name" => "夏の旅行",
|
||||
"votes_per_user" => "5",
|
||||
"grouping_mode" => "true",
|
||||
"leader_threshold" => "3"
|
||||
})
|
||||
)
|
||||
|
||||
assert room.room_number =~ ~r/^\d{6}$/
|
||||
assert room.status == "open"
|
||||
assert room.name == "夏の旅行"
|
||||
assert room.votes_per_user == 5
|
||||
assert room.grouping_mode == true
|
||||
assert room.leader_threshold == 3
|
||||
|
||||
assert owner.role == "owner"
|
||||
assert owner.name == "オーナー"
|
||||
assert is_binary(owner.token) and owner.token != ""
|
||||
assert owner.room_id == room.id
|
||||
end
|
||||
|
||||
test "rejects a blank name" do
|
||||
assert {:error, changeset} = Voting.create_room(valid_room_attrs(%{"name" => ""}))
|
||||
assert errors_on(changeset)[:name]
|
||||
end
|
||||
|
||||
test "rejects votes_per_user below 1" do
|
||||
assert {:error, changeset} =
|
||||
Voting.create_room(valid_room_attrs(%{"votes_per_user" => "0"}))
|
||||
|
||||
assert errors_on(changeset)[:votes_per_user]
|
||||
end
|
||||
|
||||
test "rejects votes_per_user above 50" do
|
||||
assert {:error, changeset} =
|
||||
Voting.create_room(valid_room_attrs(%{"votes_per_user" => "51"}))
|
||||
|
||||
assert errors_on(changeset)[:votes_per_user]
|
||||
end
|
||||
|
||||
test "a future JST closes_at_local is stored as the corresponding UTC instant" do
|
||||
assert {:ok, %{room: room}} =
|
||||
Voting.create_room(valid_room_attrs(%{"closes_at_local" => "2099-12-31T23:59"}))
|
||||
|
||||
assert room.closes_at == ~U[2099-12-31 14:59:00Z]
|
||||
end
|
||||
|
||||
test "a past closes_at_local is rejected with an error on :closes_at_local" do
|
||||
assert {:error, changeset} =
|
||||
Voting.create_room(valid_room_attrs(%{"closes_at_local" => "2000-01-01T00:00"}))
|
||||
|
||||
assert errors_on(changeset)[:closes_at_local]
|
||||
end
|
||||
|
||||
test "a blank closes_at_local leaves closes_at nil" do
|
||||
assert {:ok, %{room: room}} =
|
||||
Voting.create_room(valid_room_attrs(%{"closes_at_local" => ""}))
|
||||
|
||||
assert room.closes_at == nil
|
||||
end
|
||||
end
|
||||
|
||||
describe "join_room/2 and get_participant_by_token/1" do
|
||||
test "creates a member with a token, found by token with its room preloaded" do
|
||||
%{room: room} = new_room()
|
||||
|
||||
assert {:ok, participant} = Voting.join_room(room, %{"name" => "太郎"})
|
||||
assert participant.role == "member"
|
||||
assert participant.name == "太郎"
|
||||
assert is_binary(participant.token)
|
||||
|
||||
found = Voting.get_participant_by_token(participant.token)
|
||||
assert found.id == participant.id
|
||||
assert found.room.id == room.id
|
||||
end
|
||||
|
||||
test "get_participant_by_token(nil) returns nil" do
|
||||
assert Voting.get_participant_by_token(nil) == nil
|
||||
end
|
||||
|
||||
test "rejects a blank name" do
|
||||
%{room: room} = new_room()
|
||||
assert {:error, changeset} = Voting.join_room(room, %{"name" => ""})
|
||||
assert errors_on(changeset)[:name]
|
||||
end
|
||||
end
|
||||
|
||||
describe "create_idea/3" do
|
||||
test "normal mode allows multiple ideas from one participant" do
|
||||
%{room: room} = new_room(%{"grouping_mode" => "false"})
|
||||
{:ok, p} = Voting.join_room(room, %{"name" => "太郎"})
|
||||
|
||||
assert {:ok, i1} = Voting.create_idea(room, p, %{"text" => "焼肉に行く"})
|
||||
assert {:ok, i2} = Voting.create_idea(room, p, %{"text" => "寿司を食べる"})
|
||||
|
||||
assert i1.id != i2.id
|
||||
assert length(Voting.list_ideas(room)) == 2
|
||||
end
|
||||
|
||||
test "grouping mode keeps a single opinion per participant (second call updates)" do
|
||||
%{room: room} = new_room(%{"grouping_mode" => "true"})
|
||||
{:ok, p} = Voting.join_room(room, %{"name" => "太郎"})
|
||||
|
||||
assert {:ok, first} = Voting.create_idea(room, p, %{"text" => "公園を掃除したい"})
|
||||
assert {:ok, second} = Voting.create_idea(room, p, %{"text" => "海岸を清掃したい"})
|
||||
|
||||
assert first.id == second.id
|
||||
assert second.text == "海岸を清掃したい"
|
||||
assert length(Voting.list_ideas(room)) == 1
|
||||
assert Voting.get_participant_idea(room, p).text == "海岸を清掃したい"
|
||||
end
|
||||
|
||||
test "returns {:error, :closed} on a closed room" do
|
||||
%{room: room} = new_room()
|
||||
{:ok, p} = Voting.join_room(room, %{"name" => "太郎"})
|
||||
{:ok, closed} = Voting.close_room(room)
|
||||
|
||||
assert Voting.create_idea(closed, p, %{"text" => "焼肉"}) == {:error, :closed}
|
||||
end
|
||||
|
||||
test "returns a changeset error for blank text" do
|
||||
%{room: room} = new_room()
|
||||
{:ok, p} = Voting.join_room(room, %{"name" => "太郎"})
|
||||
|
||||
assert {:error, %Ecto.Changeset{} = changeset} =
|
||||
Voting.create_idea(room, p, %{"text" => ""})
|
||||
|
||||
assert errors_on(changeset)[:text]
|
||||
end
|
||||
end
|
||||
|
||||
describe "voting" do
|
||||
setup do
|
||||
%{room: room} = new_room(%{"votes_per_user" => "2"})
|
||||
{:ok, poster} = Voting.join_room(room, %{"name" => "投稿者"})
|
||||
{:ok, voter} = Voting.join_room(room, %{"name" => "投票者"})
|
||||
{:ok, i1} = Voting.create_idea(room, poster, %{"text" => "案1"})
|
||||
{:ok, i2} = Voting.create_idea(room, poster, %{"text" => "案2"})
|
||||
{:ok, i3} = Voting.create_idea(room, poster, %{"text" => "案3"})
|
||||
|
||||
%{room: room, poster: poster, voter: voter, ideas: [i1, i2, i3]}
|
||||
end
|
||||
|
||||
test "a successful vote is reflected across the vote-accounting helpers", ctx do
|
||||
%{room: room, voter: voter, ideas: [i1 | _]} = ctx
|
||||
|
||||
assert {:ok, _} = Voting.vote(voter, i1.id)
|
||||
assert Voting.voted?(voter, i1.id)
|
||||
assert Voting.votes_used(voter) == 1
|
||||
assert Voting.votes_remaining(room, voter) == 1
|
||||
assert MapSet.member?(Voting.voted_idea_ids(voter), i1.id)
|
||||
end
|
||||
|
||||
test "voting the same idea twice returns {:error, :already_voted}", ctx do
|
||||
%{voter: voter, ideas: [i1 | _]} = ctx
|
||||
assert {:ok, _} = Voting.vote(voter, i1.id)
|
||||
assert Voting.vote(voter, i1.id) == {:error, :already_voted}
|
||||
end
|
||||
|
||||
test "exceeding votes_per_user returns {:error, :limit_reached}", ctx do
|
||||
%{voter: voter, ideas: [i1, i2, i3]} = ctx
|
||||
assert {:ok, _} = Voting.vote(voter, i1.id)
|
||||
assert {:ok, _} = Voting.vote(voter, i2.id)
|
||||
assert Voting.vote(voter, i3.id) == {:error, :limit_reached}
|
||||
end
|
||||
|
||||
test "voting on a closed room returns {:error, :closed}", ctx do
|
||||
%{room: room, voter: voter, ideas: [i1 | _]} = ctx
|
||||
{:ok, _} = Voting.close_room(room)
|
||||
assert Voting.vote(voter, i1.id) == {:error, :closed}
|
||||
end
|
||||
|
||||
test "voting a non-existent idea returns {:error, :not_found}", ctx do
|
||||
%{voter: voter} = ctx
|
||||
assert Voting.vote(voter, -1) == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "voting for an idea from another room returns {:error, :not_found}", ctx do
|
||||
%{voter: voter} = ctx
|
||||
%{room: other} = new_room()
|
||||
{:ok, other_poster} = Voting.join_room(other, %{"name" => "外部の人"})
|
||||
{:ok, foreign} = Voting.create_idea(other, other_poster, %{"text" => "外部の案"})
|
||||
|
||||
assert Voting.vote(voter, foreign.id) == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "unvote removes the vote and frees a slot", ctx do
|
||||
%{room: room, voter: voter, ideas: [i1 | _]} = ctx
|
||||
{:ok, _} = Voting.vote(voter, i1.id)
|
||||
assert Voting.votes_remaining(room, voter) == 1
|
||||
|
||||
assert Voting.unvote(voter, i1.id) == :ok
|
||||
refute Voting.voted?(voter, i1.id)
|
||||
assert Voting.votes_remaining(room, voter) == 2
|
||||
end
|
||||
|
||||
test "unvote with no matching vote returns {:error, :not_found}", ctx do
|
||||
%{voter: voter, ideas: [i1 | _]} = ctx
|
||||
assert Voting.unvote(voter, i1.id) == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "unvote on a closed room returns {:error, :closed}", ctx do
|
||||
%{room: room, voter: voter, ideas: [i1 | _]} = ctx
|
||||
{:ok, _} = Voting.vote(voter, i1.id)
|
||||
{:ok, _} = Voting.close_room(room)
|
||||
assert Voting.unvote(voter, i1.id) == {:error, :closed}
|
||||
end
|
||||
|
||||
test "results are sorted by count descending; vote_counts maps idea id to count", ctx do
|
||||
%{room: room, poster: poster, voter: voter, ideas: [i1, i2, i3]} = ctx
|
||||
|
||||
# i2 => 2 votes, i1 => 1 vote, i3 => 0 votes.
|
||||
{:ok, _} = Voting.vote(voter, i2.id)
|
||||
{:ok, _} = Voting.vote(poster, i2.id)
|
||||
{:ok, _} = Voting.vote(poster, i1.id)
|
||||
|
||||
counts = Voting.vote_counts(room)
|
||||
assert counts[i2.id] == 2
|
||||
assert counts[i1.id] == 1
|
||||
assert Map.get(counts, i3.id) == nil
|
||||
|
||||
results = Voting.results(room)
|
||||
assert Enum.map(results, & &1.count) == [2, 1, 0]
|
||||
assert hd(results).idea.id == i2.id
|
||||
end
|
||||
|
||||
test "shuffled_ideas is stable per seed and contains exactly the room's ideas", ctx do
|
||||
%{room: room, ideas: ideas} = ctx
|
||||
expected_ids = ideas |> Enum.map(& &1.id) |> Enum.sort()
|
||||
|
||||
first = Voting.shuffled_ideas(room, "viewer-token")
|
||||
again = Voting.shuffled_ideas(room, "viewer-token")
|
||||
|
||||
assert Enum.map(first, & &1.id) == Enum.map(again, & &1.id)
|
||||
assert first |> Enum.map(& &1.id) |> Enum.sort() == expected_ids
|
||||
end
|
||||
end
|
||||
|
||||
describe "close_room/1 and effective_status/1" do
|
||||
test "close_room marks the room closed and is idempotent" do
|
||||
%{room: room} = new_room()
|
||||
|
||||
assert {:ok, closed} = Voting.close_room(room)
|
||||
assert closed.status == "closed"
|
||||
|
||||
assert {:ok, closed_again} = Voting.close_room(closed)
|
||||
assert closed_again.status == "closed"
|
||||
end
|
||||
|
||||
test "effective_status is open for an open room without a deadline" do
|
||||
%{room: room} = new_room()
|
||||
assert Voting.effective_status(room) == "open"
|
||||
assert Voting.room_open?(room)
|
||||
end
|
||||
|
||||
test "effective_status is closed once the status is closed" do
|
||||
%{room: room} = new_room()
|
||||
{:ok, closed} = Voting.close_room(room)
|
||||
assert Voting.effective_status(closed) == "closed"
|
||||
refute Voting.room_open?(closed)
|
||||
end
|
||||
|
||||
test "effective_status is closed when the deadline has passed even if status is open" do
|
||||
%{room: room} = new_room()
|
||||
past = %{room | closes_at: seconds_ago(60)}
|
||||
|
||||
assert past.status == "open"
|
||||
assert Voting.effective_status(past) == "closed"
|
||||
refute Voting.room_open?(past)
|
||||
end
|
||||
|
||||
test "ensure_closed closes an open room past its deadline" do
|
||||
%{room: room} = new_room()
|
||||
room = set_closes_at(room, seconds_ago(60))
|
||||
assert room.status == "open"
|
||||
|
||||
closed = Voting.ensure_closed(room)
|
||||
assert closed.status == "closed"
|
||||
end
|
||||
|
||||
test "ensure_closed leaves an open room within its deadline untouched" do
|
||||
%{room: room} = new_room(%{"closes_at_local" => "2099-01-01T00:00"})
|
||||
still_open = Voting.ensure_closed(room)
|
||||
assert still_open.status == "open"
|
||||
end
|
||||
|
||||
test "list_rooms_to_auto_close returns only open rooms past their deadline" do
|
||||
%{room: due} = new_room()
|
||||
due = set_closes_at(due, seconds_ago(60))
|
||||
|
||||
%{room: future} = new_room(%{"closes_at_local" => "2099-01-01T00:00"})
|
||||
%{room: no_deadline} = new_room()
|
||||
|
||||
%{room: already_closed} = new_room()
|
||||
already_closed = set_closes_at(already_closed, seconds_ago(60))
|
||||
{:ok, _} = Voting.close_room(already_closed)
|
||||
|
||||
ids = Voting.list_rooms_to_auto_close() |> Enum.map(& &1.id)
|
||||
|
||||
assert due.id in ids
|
||||
refute future.id in ids
|
||||
refute no_deadline.id in ids
|
||||
refute already_closed.id in ids
|
||||
end
|
||||
end
|
||||
|
||||
describe "grouping mode" do
|
||||
test "assigns members to leaders who reached the threshold, balanced by preference" do
|
||||
%{room: room, owner: owner} =
|
||||
new_room(%{"grouping_mode" => "true", "leader_threshold" => "2", "votes_per_user" => "3"})
|
||||
|
||||
{:ok, l1} = Voting.join_room(room, %{"name" => "リーダーA"})
|
||||
{:ok, l2} = Voting.join_room(room, %{"name" => "リーダーB"})
|
||||
{:ok, m1} = Voting.join_room(room, %{"name" => "メンバー1"})
|
||||
{:ok, m2} = Voting.join_room(room, %{"name" => "メンバー2"})
|
||||
{:ok, m3} = Voting.join_room(room, %{"name" => "メンバー3"})
|
||||
{:ok, m4} = Voting.join_room(room, %{"name" => "メンバー4"})
|
||||
|
||||
{:ok, op_a} = Voting.create_idea(room, l1, %{"text" => "案A"})
|
||||
{:ok, op_b} = Voting.create_idea(room, l2, %{"text" => "案B"})
|
||||
{:ok, _} = Voting.create_idea(room, m1, %{"text" => "案1"})
|
||||
{:ok, _} = Voting.create_idea(room, m2, %{"text" => "案2"})
|
||||
{:ok, _} = Voting.create_idea(room, m3, %{"text" => "案3"})
|
||||
{:ok, _} = Voting.create_idea(room, m4, %{"text" => "案4"})
|
||||
|
||||
# Both opinions reach the threshold of 2 (so both become leaders).
|
||||
# M1 prefers BOTH opinions, M2/M3 prefer A, M4 prefers B. A naive
|
||||
# "everyone to their top choice" would overload leader A (M1,M2,M3),
|
||||
# so the balanced assignment must move M1 onto leader B.
|
||||
{:ok, _} = Voting.vote(m1, op_a.id)
|
||||
{:ok, _} = Voting.vote(m1, op_b.id)
|
||||
{:ok, _} = Voting.vote(m2, op_a.id)
|
||||
{:ok, _} = Voting.vote(m3, op_a.id)
|
||||
{:ok, _} = Voting.vote(m4, op_b.id)
|
||||
|
||||
{:ok, closed} = Voting.close_room(room)
|
||||
%{groups: groups, ungrouped: ungrouped} = Voting.group_assignments(closed)
|
||||
|
||||
leader_ids = groups |> Enum.map(& &1.leader.id) |> Enum.sort()
|
||||
assert leader_ids == Enum.sort([l1.id, l2.id])
|
||||
assert ungrouped == []
|
||||
|
||||
# Every non-leader member is placed in exactly one group.
|
||||
placed = groups |> Enum.flat_map(& &1.members) |> Enum.map(& &1.id)
|
||||
assert Enum.sort(placed) == Enum.sort([m1.id, m2.id, m3.id, m4.id])
|
||||
assert length(placed) == length(Enum.uniq(placed))
|
||||
|
||||
# Balanced group sizes (differ by at most one); here forced to 2 and 2.
|
||||
sizes = Enum.map(groups, &length(&1.members))
|
||||
assert Enum.max(sizes) - Enum.min(sizes) <= 1
|
||||
|
||||
# The owner is never a leader nor placed in a group.
|
||||
refute owner.id in (leader_ids ++ placed)
|
||||
|
||||
# A zero-cost balanced assignment exists, so every placed member ended up
|
||||
# with a leader whose opinion they actually voted for.
|
||||
opinion_by_leader = Map.new(groups, fn g -> {g.leader.id, g.opinion.id} end)
|
||||
|
||||
for group <- groups, member <- group.members do
|
||||
assert MapSet.member?(Voting.voted_idea_ids(member), opinion_by_leader[group.leader.id]),
|
||||
"member #{member.name} should be grouped with a leader whose opinion they voted for"
|
||||
end
|
||||
end
|
||||
|
||||
test "when no opinion reaches the threshold, there are no groups and all members are ungrouped" do
|
||||
%{room: room, owner: owner} =
|
||||
new_room(%{"grouping_mode" => "true", "leader_threshold" => "5", "votes_per_user" => "3"})
|
||||
|
||||
{:ok, a} = Voting.join_room(room, %{"name" => "A"})
|
||||
{:ok, b} = Voting.join_room(room, %{"name" => "B"})
|
||||
{:ok, c} = Voting.join_room(room, %{"name" => "C"})
|
||||
|
||||
{:ok, op_a} = Voting.create_idea(room, a, %{"text" => "案A"})
|
||||
{:ok, op_b} = Voting.create_idea(room, b, %{"text" => "案B"})
|
||||
{:ok, _} = Voting.create_idea(room, c, %{"text" => "案C"})
|
||||
|
||||
{:ok, _} = Voting.vote(a, op_b.id)
|
||||
{:ok, _} = Voting.vote(b, op_a.id)
|
||||
|
||||
{:ok, closed} = Voting.close_room(room)
|
||||
%{groups: groups, ungrouped: ungrouped} = Voting.group_assignments(closed)
|
||||
|
||||
assert groups == []
|
||||
ungrouped_ids = ungrouped |> Enum.map(& &1.id) |> Enum.sort()
|
||||
assert ungrouped_ids == Enum.sort([a.id, b.id, c.id])
|
||||
refute owner.id in ungrouped_ids
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user