diff --git a/.formatter.exs b/.formatter.exs index 8a6391c..21f7ecd 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -1,5 +1,5 @@ [ - import_deps: [:ecto, :phoenix], + import_deps: [:ecto, :phoenix, :stream_data], inputs: ["*.{ex,exs}", "priv/*/seeds.exs", "{config,lib,test}/**/*.{ex,exs}"], subdirectories: ["priv/*/migrations"] ] diff --git a/README.md b/README.md index 0641444..190dc85 100644 --- a/README.md +++ b/README.md @@ -3,4 +3,40 @@ Welcome to Elixir School's workshop on Real-Time Phoenix with Channels, PubSub, Collaborators can sign in with a username and click a button to start the "pointing party". Tickets are displayed for estimation and each user can cast their store point estimate vote. Once all the votes are cast, a winner is declared and the participants can move on to estimate the next ticket. -The master branch of this app represents the starting state of the code for this workshop. Clone down and make sure you're on the master branch in order to follow along. +The master branch of this app represents the starting state of the code for this workshop. Clone down and make sure you're on the master branch in order to follow along. + +## Resources + +### Phoenix Channels + +* [Official guide](https://hexdocs.pm/phoenix/channels.html) +* [API documentation](https://hexdocs.pm/phoenix/Phoenix.Channel.html#content) + +### Phoenix PubSub + +* [API documentation](https://hexdocs.pm/phoenix_pubsub/Phoenix.PubSub.html) + +### Phoenix LiveView + +* [LiveView announcement](https://dockyard.com/blog/2018/12/12/phoenix-liveview-interactive-real-time-apps-no-need-to-write-javascript) by Chris McCord +* [API documentation](https://hexdocs.pm/phoenix_live_view/Phoenix.LiveView.html) +* ["Walk-Through of Phoenix LiveView"](https://elixirschool.com/blog/phoenix-live-view/) by Sophie DeBenedetto +* ["Building Real-Time Features with Phoenix Live View and PubSub"](https://elixirschool.com/blog/live-view-with-pub-sub/) by Sophie DeBenedetto +* ["Using Channels with LiveView for Better UX"](https://elixirschool.com/blog/live-view-with-channels/) by Sophie DeBenedetto +* ["Tracking Users in a Chat App with LiveView, PubSub Presence"](https://elixirschool.com/blog/live-view-with-presence/) by Sophie DeBenedetto + +### Property-based Testing and StreamData + +* [StreamData on GitHub](https://github.com/whatyouhide/stream_data) +* [StreamData documentation](https://hexdocs.pm/stream_data/StreamData.html) +* [Elixir School article on StreamData](https://elixirschool.com/en/lessons/libraries/stream-data/) +* [_Property-Based Testing with PropEr, Erlang, and Elixir_ and _PropEr Testing_](https://propertesting.com/) by Fred Hebert +* ["An introduction to property-based testing"](https://fsharpforfunandprofit.com/posts/property-based-testing/) by Scott Wlaschin +* ["Choosing properties for property-based testing"](https://fsharpforfunandprofit.com/posts/property-based-testing-2/) by Scott Wlaschin + +### Estimation + +* [_Agile Estimating and Planning_](https://www.mountaingoatsoftware.com/books/agile-estimating-and-planning) by Mike Cohn of Mountain Goat Software +* [planningpoker.com](https://www.planningpoker.com/) is a full-featured estimation tool that may work well for your team. + +Thanks to James Grenning and Mike Cohn for their inspiration and their work in software estimation! diff --git a/config/cards.exs b/config/cards.exs index e28c62a..a74300f 100644 --- a/config/cards.exs +++ b/config/cards.exs @@ -15,7 +15,6 @@ config :pointing_party, Update the application to save an individual's vote and the final card points to the database. """ }, - %{ title: "Add Guardian dependency", description: """ @@ -34,5 +33,5 @@ config :pointing_party, description: """ Update our existing authentication flow to use the newly created Auth module. """ - }, + } ] diff --git a/lib/pointing_party/account.ex b/lib/pointing_party/account.ex index 5101acf..15f3247 100644 --- a/lib/pointing_party/account.ex +++ b/lib/pointing_party/account.ex @@ -11,6 +11,7 @@ defmodule PointingParty.Account do def create(attrs) do changeset = changeset(%Account{}, attrs) + if changeset.valid? do account = apply_changes(changeset) {:ok, account} diff --git a/lib/pointing_party/vote_calculator.ex b/lib/pointing_party/vote_calculator.ex index 08b0886..363a98a 100644 --- a/lib/pointing_party/vote_calculator.ex +++ b/lib/pointing_party/vote_calculator.ex @@ -19,11 +19,7 @@ defmodule PointingParty.VoteCalculator do end defp get_points(score_card, users) do - votes = - users - |> Enum.map(fn {_username, %{metas: [%{points: points}]}} -> - points - end) + votes = Enum.map(users, fn {_username, %{metas: [%{points: points}]}} -> points end) update_score_card(score_card, :votes, votes) end @@ -57,9 +53,10 @@ defmodule PointingParty.VoteCalculator do defp handle_tie(%{majority: nil, calculated_votes: calculated_votes}) do calculated_votes - |> Enum.sort_by(&elem(&1, 1)) - |> Enum.take(2) + |> Enum.sort_by(&elem(&1, 1), &>=/2) |> Enum.map(&elem(&1, 0)) + |> Enum.sort() + |> Enum.take(2) end defp handle_tie(%{majority: majority}), do: majority diff --git a/lib/pointing_party_web/channels/presence.ex b/lib/pointing_party_web/channels/presence.ex index 7bccdf8..a21ad08 100644 --- a/lib/pointing_party_web/channels/presence.ex +++ b/lib/pointing_party_web/channels/presence.ex @@ -1,4 +1,5 @@ defmodule PointingPartyWeb.Presence do - use Phoenix.Presence, otp_app: :pointing_party, - pubsub_server: PointingParty.PubSub + use Phoenix.Presence, + otp_app: :pointing_party, + pubsub_server: PointingParty.PubSub end diff --git a/lib/pointing_party_web/channels/room_channel.ex b/lib/pointing_party_web/channels/room_channel.ex index 9c1b91d..8195dde 100644 --- a/lib/pointing_party_web/channels/room_channel.ex +++ b/lib/pointing_party_web/channels/room_channel.ex @@ -32,6 +32,7 @@ defmodule PointingPartyWeb.RoomChannel do end defp initialize_state(%{assigns: %{cards: _cards}} = socket), do: socket + defp initialize_state(socket) do [first | cards] = Card.cards() diff --git a/lib/pointing_party_web/controllers/session_controller.ex b/lib/pointing_party_web/controllers/session_controller.ex index efcb5ad..61eb51e 100644 --- a/lib/pointing_party_web/controllers/session_controller.ex +++ b/lib/pointing_party_web/controllers/session_controller.ex @@ -15,13 +15,13 @@ defmodule PointingPartyWeb.SessionController do |> put_session(:username, username) |> redirect(to: "/cards") |> halt() + {:error, changeset} -> render(conn, "new.html", changeset: changeset) end end def delete(conn, _params) do - clear_session(conn) - |> redirect(to: "/login") |> halt() + clear_session(conn) |> redirect(to: "/login") |> halt() end end diff --git a/lib/pointing_party_web/plugs/auth.ex b/lib/pointing_party_web/plugs/auth.ex index d92aa38..5d899fc 100644 --- a/lib/pointing_party_web/plugs/auth.ex +++ b/lib/pointing_party_web/plugs/auth.ex @@ -12,6 +12,6 @@ defmodule PointingPartyWeb.Plugs.Auth do end defp authenticate(conn) do - get_session(conn, :username) + get_session(conn, :username) end end diff --git a/mix.exs b/mix.exs index 810e845..ca13ddb 100644 --- a/mix.exs +++ b/mix.exs @@ -41,7 +41,9 @@ defmodule PointingParty.MixProject do {:gettext, "~> 0.11"}, {:jason, "~> 1.0"}, {:plug_cowboy, "~> 2.0"}, - {:ex_machina, "~> 2.3", only: :test} + {:ex_machina, "~> 2.3", only: :test}, + {:mix_test_watch, "~> 0.8", only: :dev, runtime: false}, + {:stream_data, "~> 0.1", only: :test} ] end end diff --git a/mix.lock b/mix.lock index 1c676a0..b5fcac9 100644 --- a/mix.lock +++ b/mix.lock @@ -11,6 +11,7 @@ "gettext": {:hex, :gettext, "0.17.0", "abe21542c831887a2b16f4c94556db9c421ab301aee417b7c4fbde7fbdbe01ec", [:mix], [], "hexpm"}, "jason": {:hex, :jason, "1.1.2", "b03dedea67a99223a2eaf9f1264ce37154564de899fd3d8b9a21b1a6fd64afe7", [:mix], [{:decimal, "~> 1.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm"}, "mime": {:hex, :mime, "1.3.1", "30ce04ab3175b6ad0bdce0035cba77bba68b813d523d1aac73d9781b4d193cf8", [:mix], [], "hexpm"}, + "mix_test_watch": {:hex, :mix_test_watch, "0.9.0", "c72132a6071261893518fa08e121e911c9358713f62794a90c95db59042af375", [:mix], [{:file_system, "~> 0.2.1 or ~> 0.3", [hex: :file_system, repo: "hexpm", optional: false]}], "hexpm"}, "phoenix": {:hex, :phoenix, "1.4.9", "746d098e10741c334d88143d3c94cab1756435f94387a63441792e66ec0ee974", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 1.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:plug, "~> 1.8.1 or ~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 1.0 or ~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.0.0", "c43117a136e7399ea04ecaac73f8f23ee0ffe3e07acfcb8062fe5f4c9f0f6531", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.9", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, "phoenix_html": {:hex, :phoenix_html, "2.13.3", "850e292ff6e204257f5f9c4c54a8cb1f6fbc16ed53d360c2b780a3d0ba333867", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm"}, @@ -21,5 +22,6 @@ "plug_crypto": {:hex, :plug_crypto, "1.0.0", "18e49317d3fa343f24620ed22795ec29d4a5e602d52d1513ccea0b07d8ea7d4d", [:mix], [], "hexpm"}, "postgrex": {:hex, :postgrex, "0.14.3", "5754dee2fdf6e9e508cbf49ab138df964278700b764177e8f3871e658b345a1e", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.0", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm"}, "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm"}, + "stream_data": {:hex, :stream_data, "0.4.3", "62aafd870caff0849a5057a7ec270fad0eb86889f4d433b937d996de99e3db25", [:mix], [], "hexpm"}, "telemetry": {:hex, :telemetry, "0.4.0", "8339bee3fa8b91cb84d14c2935f8ecf399ccd87301ad6da6b71c09553834b2ab", [:rebar3], [], "hexpm"}, } diff --git a/priv/repo/migrations/20190708014847_create_cards.exs b/priv/repo/migrations/20190708014847_create_cards.exs index 7f3a6f9..7088367 100644 --- a/priv/repo/migrations/20190708014847_create_cards.exs +++ b/priv/repo/migrations/20190708014847_create_cards.exs @@ -3,11 +3,10 @@ defmodule PointingParty.Repo.Migrations.CreateCards do def change do create table(:cards) do - add :title, :string - add :description, :string + add(:title, :string) + add(:description, :string) timestamps() end - end end diff --git a/priv/repo/migrations/20190714140153_add_points_to_cards.exs b/priv/repo/migrations/20190714140153_add_points_to_cards.exs index da3aa8e..792c45c 100644 --- a/priv/repo/migrations/20190714140153_add_points_to_cards.exs +++ b/priv/repo/migrations/20190714140153_add_points_to_cards.exs @@ -3,7 +3,7 @@ defmodule PointingParty.Repo.Migrations.AddPointsToCards do def change do alter table("cards") do - add :points, :integer, default: 0 + add(:points, :integer, default: 0) end end end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index f0db673..8c92951 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -10,7 +10,6 @@ # We recommend using the bang functions (`insert!`, `update!` # and so on) as they will fail if something goes wrong. - alias PointingParty.{Card, Repo} cards = [ @@ -21,7 +20,7 @@ cards = [ %{title: "Fifth card", description: "This is a description of the fifth card."}, %{title: "Sixth card", description: "This is a description of the sixth card."}, %{title: "Seventh card", description: "This is a description of the seventh card."}, - %{title: "Eighth card", description: "This is a description of the eighth card."}, + %{title: "Eighth card", description: "This is a description of the eighth card."} ] Enum.each(cards, fn card -> diff --git a/test/pointing_party/vote_calculator_test.exs b/test/pointing_party/vote_calculator_test.exs index d372207..e73f7b1 100644 --- a/test/pointing_party/vote_calculator_test.exs +++ b/test/pointing_party/vote_calculator_test.exs @@ -1,23 +1,96 @@ defmodule PointingParty.VoteCalculatorTest do use ExUnit.Case, async: true + use ExUnitProperties - @users_with_winner %{ - "sean" => %{metas: [%{points: 1}]}, - "michael" => %{metas: [%{points: 3}]}, - "sophie" => %{metas: [%{points: 3}]} - } + alias PointingParty.{Card, VoteCalculator} - @users_with_tie %{ - "sean" => %{metas: [%{points: 1}]}, - "michael" => %{metas: [%{points: 2}]}, - "sophie" => %{metas: [%{points: 3}]} - } + # Properties + # -------------- + # When there is a tie, the result will be a list + # The tie list will be sorted in increasing order + # The tie list will contain unique elements + # The tie list will have exactly two elements + # The tie list contains only integers + # The tie list integers must be valid voting options + # The greatest element in the tie list will not be greater than the highest vote + # + # When there is a winner, the result will be an integer + # When there is a winner, the result will be one of the valid voting options + # When there is a winner, it will not be greater than the highest vote + # + # Notes + # -------------- + # Ties can happen when there are even or odd numbers of players + # Winning votes can also have an even or odd number of players + # Single-player games will always have a winner - test "calculate_votes/1 calculates when there is a winner" do - {"winner", 3} = PointingParty.VoteCalculator.calculate_votes(@users_with_winner) - end - test "calculate_votes/1 calculates when there is a tie" do - {"tie", [1,2]} = PointingParty.VoteCalculator.calculate_votes(@users_with_tie) + describe "calculate_votes/1" do + setup do + points_map = fixed_map(%{ + points: member_of(Card.points_range()) + }) + metas_map = fixed_map(%{ + metas: list_of(points_map, length: 1) + }) + user_generator = nonempty(map_of(string(:alphanumeric), metas_map)) + + [user_generator: user_generator] + end + + property "calculated vote is a list or an integer", %{user_generator: user_generator} do + check all users <- user_generator, + {_event, winner} = VoteCalculator.calculate_votes(users), + max_runs: 20 do + assert is_list(winner) || is_integer(winner) + end + end + + property "the winning value is not more than the highest vote", %{user_generator: user_generator} do + check all users <- user_generator, + max_runs: 20 do + max_vote = + users + |> Enum.map(fn {_username, %{metas: [%{points: points}]}} -> points end) + |> Enum.max() + + case PointingParty.VoteCalculator.calculate_votes(users) do + {"winner", winner} -> assert winner <= max_vote + {"tie", [_lesser, greater]} -> assert greater <= max_vote + end + end + end + + property "when there is a winner, calculated vote is a valid integer", %{user_generator: user_generator} do + check all users <- user_generator, + {event, winner} = PointingParty.VoteCalculator.calculate_votes(users), + max_runs: 20 do + if event == "winner" do + assert winner in Card.points_range() + end + end + end + + property "when there is a tie, calculated vote is a list with two sorted values", %{user_generator: user_generator} do + check all users <- user_generator, + {event, votes} = PointingParty.VoteCalculator.calculate_votes(users), + max_runs: 20 do + if event == "tie" do + [lesser, greater] = votes + + assert lesser < greater + end + end + end + + property "when there is a tie, calculated vote is a list of valid integers", %{user_generator: user_generator} do + check all users <- user_generator, + {event, votes} = PointingParty.VoteCalculator.calculate_votes(users), + max_runs: 20 do + if event == "tie" do + assert Enum.all?(votes, fn vote -> vote in Card.points_range() end) + end + end + end end end diff --git a/test/pointing_party_web/controllers/card_controller_test.exs b/test/pointing_party_web/controllers/card_controller_test.exs index 66a9b63..204b19e 100644 --- a/test/pointing_party_web/controllers/card_controller_test.exs +++ b/test/pointing_party_web/controllers/card_controller_test.exs @@ -4,7 +4,7 @@ defmodule PointingPartyWeb.CardControllerTest do @username "test_user" describe "authenticated user" do - setup %{conn: conn} do + setup %{conn: conn} do auth_conn = Plug.Test.init_test_session(conn, username: @username) {:ok, %{conn: auth_conn}} end diff --git a/test/pointing_party_web/controllers/page_controller_test.exs b/test/pointing_party_web/controllers/page_controller_test.exs index e94669c..56159c8 100644 --- a/test/pointing_party_web/controllers/page_controller_test.exs +++ b/test/pointing_party_web/controllers/page_controller_test.exs @@ -4,9 +4,9 @@ defmodule PointingPartyWeb.PageControllerTest do @username "test_user" describe "authenticated user" do - setup %{conn: conn} do - conn = conn - |> Plug.Conn.assign(:username, @username) + setup %{conn: conn} do + conn = Plug.Conn.assign(conn, :username, @username) + {:ok, %{conn: conn}} end