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