Files
rsh26_pool_example/test/rsh26pool/voting_test.exs
T

426 lines
16 KiB
Elixir
Raw Normal View History

2026-07-04 19:27:17 +09:00
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