This commit is contained in:
2026-07-04 19:27:17 +09:00
parent 34cd5ad2e7
commit ba6a0b2c17
30 changed files with 2935 additions and 247 deletions
+61
View File
@@ -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
+92
View File
@@ -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
+425
View File
@@ -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