diff --git a/.gitignore b/.gitignore index 354fd11..b3b547a 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,9 @@ chesstrainer-*.tar # Ignore assets that are produced by build tools. /priv/static/assets/ +# Ignore ETS table backups +/priv/static/backups/ + # Ignore digested assets cache. /priv/static/cache_manifest.json diff --git a/assets/js/hooks/chessBoard.js b/assets/js/hooks/chessBoard.js index 68f796c..5270610 100644 --- a/assets/js/hooks/chessBoard.js +++ b/assets/js/hooks/chessBoard.js @@ -4,11 +4,70 @@ import { Chess } from "chess.js"; export const ChessBoard = { mounted() { this.chess = new Chess(); + this.ground = null; + + this.handleEvent("init_board", (payload) => { + this.initBoard(payload); + }); + + this.handleEvent("set_fen", ({ fen }) => { + try { + this.chess.load(fen); + + if (this.ground) { + const currentTurnColor = this.chess.turn() === "w" ? "white" : "black"; + this.ground.set({ + fen: fen, + turnColor: currentTurnColor, + movable: { + color: currentTurnColor, + dests: this.getValidDests(), + }, + }); + } else { + this.initBoard({ + fen, + orientation: "white", + player_color: "white", + }); + } + } catch (error) { + console.error("Invalid FEN string submitted:", error); + alert("Invalid FEN configuration!"); + } + }); + + this.initBoard({ + fen: this.chess.fen(), + orientation: "white", + player_color: "white", + }); + }, + + initBoard({ fen, orientation, player_color }) { + try { + this.chess.load(fen); + } catch (error) { + console.error("Invalid FEN from server:", fen, error); + this.pushEvent("chess_fen_invalid", { + fen: fen, + message: error && error.message ? error.message : String(error), + }); + return; + } + + if (this.ground) { + this.ground.destroy(); + } + + const turnColor = this.chess.turn() === "w" ? "white" : "black"; this.ground = Chessground(this.el, { - fen: this.chess.fen(), + fen: fen, + orientation: orientation, + turnColor: turnColor, movable: { - color: "white", + color: player_color, free: false, dests: this.getValidDests(), }, @@ -18,26 +77,6 @@ export const ChessBoard = { }, }, }); - - this.handleEvent("set_fen", ({ fen }) => { - try { - this.chess.load(fen); - - const currentTurnColor = this.chess.turn() === "w" ? "white" : "black"; - - this.ground.set({ - fen: fen, - turnColor: currentTurnColor, - movable: { - color: currentTurnColor, - dests: this.getValidDests(), - }, - }); - } catch (error) { - console.error("Invalid FEN string submitted:", error); - alert("Invalid FEN configuration!"); - } - }); }, getValidDests() { @@ -56,7 +95,7 @@ export const ChessBoard = { const destRank = dest[1]; if (piece?.role === "pawn" && (destRank === "1" || destRank === "8")) { this.showPromotionDialog(orig, dest); - return; // don't execute yet — wait for user choice + return; } this.executeMove(orig, dest); }, @@ -98,12 +137,23 @@ export const ChessBoard = { const move = this.chess.move({ from: orig, to: dest, promotion }); if (move) { - this.pushEvent("move_played", { + const eventName = + this.el.dataset && this.el.dataset.endgameId + ? "endgame_move_played" + : "move_played"; + + const payload = { from: orig, to: dest, fen: this.chess.fen(), san: move.san, - }); + }; + + if (eventName === "endgame_move_played") { + payload.endgame_id = this.el.dataset.endgameId; + } + + this.pushEvent(eventName, payload); const nextTurnColor = this.chess.turn() === "w" ? "white" : "black"; diff --git a/lib/chesstrainer/endgames/endgame.ex b/lib/chesstrainer/endgames/endgame.ex index 65a79d9..3055c9e 100644 --- a/lib/chesstrainer/endgames/endgame.ex +++ b/lib/chesstrainer/endgames/endgame.ex @@ -30,12 +30,92 @@ defmodule Chesstrainer.Endgames.Endgame do @doc false def changeset(endgame, attrs) do endgame - |> cast(attrs, [:fen, :key, :message, :notes, :result, :rating, :color]) + |> cast(normalize_attrs(attrs), [:fen, :key, :message, :notes, :result, :rating, :color]) |> validate_required([:fen, :color], message: "Invalid FEN") |> validate_required([:key, :result, :rating]) |> validate_format(:key, ~r/^(?=.{5,10}$)KQ*R*[NB]*P*\sv\sKQ*R*[NB]*P*$/, message: "Key must follow pattern KQ.. v KQR.., max pieces 7" ) + |> validate_change(:fen, fn :fen, fen -> + case fen_issues(fen) do + [] -> [] + issues -> [fen: "is not a valid FEN: #{Enum.join(issues, "; ")}"] + end + end) |> unique_constraint(:fen, message: "FEN already exists") end + + defp normalize_attrs(%{"fen" => fen} = attrs) when is_binary(fen), + do: Map.put(attrs, "fen", Chesstrainer.FEN.normalize(fen)) + + defp normalize_attrs(%{fen: fen} = attrs) when is_binary(fen), + do: Map.put(attrs, :fen, Chesstrainer.FEN.normalize(fen)) + + defp normalize_attrs(attrs), do: attrs + + # Strict structural validation. Mirrors chess.js rules so the FEN survives + # both the server-side parser (Chex, permissive) and the client-side parser + # (chess.js, strict). + defp fen_issues(fen) do + case String.split(fen, " ") do + [board, active, _castling, _en_passant, halfmove, fullmove] -> + issues = [] + + issues = + if valid_board?(board), + do: issues, + else: ["board must be 8 ranks of 8 squares each"] + + issues = + if active in ["w", "b"], + do: issues, + else: ["active color must be 'w' or 'b'"] + + issues = + if non_negative_integer?(halfmove), + do: issues, + else: ["halfmove clock must be a non-negative integer"] + + issues = + if positive_integer?(fullmove), + do: issues, + else: ["fullmove number must be a positive integer"] + + issues + + _ -> + ["must have 6 space-separated fields"] + end + end + + defp valid_board?(board) do + ranks = String.split(board, "/") + + length(ranks) == 8 and Enum.all?(ranks, &valid_rank?/1) + end + + defp valid_rank?(rank) do + squares = + rank + |> String.graphemes() + |> Enum.reduce(0, fn + g, acc when byte_size(g) == 1 -> + c = :binary.first(g) + + if c >= ?1 and c <= ?8 do + acc + (c - ?0) + else + acc + 1 + end + end) + + squares == 8 + end + + defp non_negative_integer?(str), do: integer_string?(str) and String.to_integer(str) >= 0 + defp positive_integer?(str), do: integer_string?(str) and String.to_integer(str) >= 1 + + defp integer_string?(str) do + Regex.match?(~r/^[0-9]+$/, str) + end end diff --git a/lib/chesstrainer/fen.ex b/lib/chesstrainer/fen.ex index b23b003..eca1ec2 100644 --- a/lib/chesstrainer/fen.ex +++ b/lib/chesstrainer/fen.ex @@ -7,6 +7,39 @@ defmodule Chesstrainer.FEN do |> color_initial_to_color_atom() end + @doc """ + Normalizes a FEN string for storage. + + Trims surrounding whitespace and rewrites a fullmove number of `"0"` to + `"1"`. The fullmove rewrite is a chess.js-compatibility fix — chess.js + rejects fullmove `0` as invalid even though many board editors default + to it. Other FEN fields are left untouched; structural errors are + surfaced by the Endgame changeset, not silently masked here. + + ## Examples + + iex> Chesstrainer.FEN.normalize(" 8/8/3k4/8/8/3K1R2/8/8 w - - 0 0\\n ") + "8/8/3k4/8/8/3K1R2/8/8 w - - 0 1" + + iex> Chesstrainer.FEN.normalize("8/8/3k4/8/8/3K1R2/8/8 w - - 0 5") + "8/8/3k4/8/8/3K1R2/8/8 w - - 0 5" + + iex> Chesstrainer.FEN.normalize("not a fen") + "not a fen" + """ + @spec normalize(String.t()) :: String.t() + def normalize(fen) when is_binary(fen) do + trimmed = String.trim(fen) + + case String.split(trimmed, " ") do + [board, active, castling, en_passant, halfmove, "0"] -> + Enum.join([board, active, castling, en_passant, halfmove, "1"], " ") + + _ -> + trimmed + end + end + defp color_initial_to_color_atom("b"), do: :black defp color_initial_to_color_atom("w"), do: :white defp color_initial_to_color_atom(_), do: nil diff --git a/lib/chesstrainer/tags.ex b/lib/chesstrainer/tags.ex index 42c1efc..27d099a 100644 --- a/lib/chesstrainer/tags.ex +++ b/lib/chesstrainer/tags.ex @@ -101,4 +101,23 @@ defmodule Chesstrainer.Tags do def change_tag(%Tag{} = tag, attrs \\ %{}) do Tag.changeset(tag, attrs) end + + def search("", _category), do: [] + + def search(query, category) when is_binary(query) do + pattern = "%#{query}%" + + Tag + |> where([t], ilike(t.name, ^pattern) and t.category == ^category) + |> order_by([t], asc: t.name) + |> limit(20) + |> Repo.all() + end + + def get_or_create(name, category) when is_binary(name) do + case Repo.get_by(Tag, name: name, category: category) do + nil -> create_tag(%{name: name, category: category}) + tag -> {:ok, tag} + end + end end diff --git a/lib/chesstrainer_web/components/layouts.ex b/lib/chesstrainer_web/components/layouts.ex index 1f36898..e8d199c 100644 --- a/lib/chesstrainer_web/components/layouts.ex +++ b/lib/chesstrainer_web/components/layouts.ex @@ -34,6 +34,20 @@ defmodule ChesstrainerWeb.Layouts do slot :inner_block, required: true def app(assigns) do + ~H""" +
+
+ {render_slot(@inner_block)} +
+
+ <.flash_group flash={@flash} /> + """ + end + + @doc """ + Renders the application navbar. + """ + def navbar(assigns) do ~H""" - -
-
- {render_slot(@inner_block)} -
-
- - <.flash_group flash={@flash} /> """ end diff --git a/lib/chesstrainer_web/components/layouts/root.html.heex b/lib/chesstrainer_web/components/layouts/root.html.heex index 9bb7846..fbd2996 100644 --- a/lib/chesstrainer_web/components/layouts/root.html.heex +++ b/lib/chesstrainer_web/components/layouts/root.html.heex @@ -40,6 +40,7 @@ + {@inner_content} diff --git a/lib/chesstrainer_web/controllers/page_html/home.html.heex b/lib/chesstrainer_web/controllers/page_html/home.html.heex index 5a550fd..43c96ee 100644 --- a/lib/chesstrainer_web/controllers/page_html/home.html.heex +++ b/lib/chesstrainer_web/controllers/page_html/home.html.heex @@ -1,43 +1,4 @@ -
<.link navigate={~p"/admin/endgames/#{endgame}"}>Show
+ <.link navigate={~p"/endgames/#{endgame}/play"}>Play <.link navigate={~p"/admin/endgames/#{endgame}/edit"}>Edit <:action :let={{id, endgame}}> diff --git a/lib/chesstrainer_web/live/endgame_live/play.ex b/lib/chesstrainer_web/live/endgame_live/play.ex new file mode 100644 index 0000000..cca5edf --- /dev/null +++ b/lib/chesstrainer_web/live/endgame_live/play.ex @@ -0,0 +1,119 @@ +defmodule ChesstrainerWeb.EndgameLive.Play do + use ChesstrainerWeb, :live_view + + alias Chesstrainer.Endgames + + @impl true + def render(assigns) do + ~H""" + + <.header> + Play endgame {@endgame.key} + <:subtitle> + {@endgame.color} to move · Result: {@endgame.result} · Rating: {@endgame.rating} + + <:actions> + <.button navigate={~p"/endgames"}> + <.icon name="hero-arrow-left" /> Back to endgames + + + + +
+
+

Last Played Move:

+

{@last_from_to}

+

{@last_san}

+

+ {if @move_list == [] do + "No Moves" + else + Enum.reverse(@move_list) |> Enum.join(", ") + end} +

+
+
+ <.button phx-click="reset" variant="primary"> + <.icon name="hero-arrow-path" /> Reset + +
+
+ +
+
+
+
+ + <.list> + <:item title="Fen">{@endgame.fen} + <:item title="Key">{@endgame.key} + <:item title="Color">{@endgame.color} + <:item title="Result">{@endgame.result} + <:item title="Rating">{@endgame.rating} + +
+ """ + end + + @impl true + def mount(%{"id" => id}, _session, socket) do + endgame = Endgames.get_endgame!(id) + + socket = + socket + |> assign(:page_title, "Play endgame") + |> assign(:endgame, endgame) + |> assign(:move_list, []) + |> assign(:last_san, "None yet") + |> assign(:last_from_to, "None yet") + |> push_event("init_board", %{ + fen: endgame.fen, + orientation: Atom.to_string(endgame.color), + player_color: Atom.to_string(endgame.color) + }) + + {:ok, socket} + end + + @impl true + def handle_event( + "endgame_move_played", + %{"from" => from, "to" => to, "san" => san}, + socket + ) do + # TODO: server-side legality check + tablebase evaluation when wired + {:noreply, + socket + |> assign(:last_from_to, "#{from} ➔ #{to}") + |> assign(:last_san, san) + |> assign(:move_list, [san | socket.assigns.move_list])} + end + + def handle_event("chess_fen_invalid", %{"fen" => fen, "message" => message}, socket) do + {:noreply, + put_flash( + socket, + :error, + "Endgame FEN is invalid and the board cannot be loaded: #{message} (#{fen})" + )} + end + + def handle_event("reset", _params, socket) do + {:noreply, + socket + |> assign(:move_list, []) + |> assign(:last_san, "None yet") + |> assign(:last_from_to, "None yet") + |> push_event("init_board", %{ + fen: socket.assigns.endgame.fen, + orientation: Atom.to_string(socket.assigns.endgame.color), + player_color: Atom.to_string(socket.assigns.endgame.color) + })} + end +end diff --git a/lib/chesstrainer_web/live/endgame_live/player_index.ex b/lib/chesstrainer_web/live/endgame_live/player_index.ex new file mode 100644 index 0000000..4311f9a --- /dev/null +++ b/lib/chesstrainer_web/live/endgame_live/player_index.ex @@ -0,0 +1,38 @@ +defmodule ChesstrainerWeb.EndgameLive.PlayerIndex do + use ChesstrainerWeb, :live_view + + alias Chesstrainer.Endgames + + @impl true + def render(assigns) do + ~H""" + + <.header> + Endgames + <:subtitle>Pick an endgame to play. + + + <.table + id="player-endgames" + rows={@streams.endgames} + > + <:col :let={{_id, endgame}} label="Key">{endgame.key} + <:col :let={{_id, endgame}} label="Color">{endgame.color} + <:col :let={{_id, endgame}} label="Result">{endgame.result} + <:col :let={{_id, endgame}} label="Rating">{endgame.rating} + <:action :let={{_id, endgame}}> + <.link navigate={~p"/endgames/#{endgame}/play"}>Play + + + + """ + end + + @impl true + def mount(_params, _session, socket) do + {:ok, + socket + |> assign(:page_title, "Endgames") + |> stream(:endgames, Endgames.list_endgames())} + end +end diff --git a/lib/chesstrainer_web/live/endgame_live/show.ex b/lib/chesstrainer_web/live/endgame_live/show.ex index c5d6811..3900adb 100644 --- a/lib/chesstrainer_web/live/endgame_live/show.ex +++ b/lib/chesstrainer_web/live/endgame_live/show.ex @@ -31,6 +31,9 @@ defmodule ChesstrainerWeb.EndgameLive.Show do <.button navigate={~p"/admin/endgames"}> <.icon name="hero-arrow-left" /> + <.button navigate={~p"/endgames/#{@endgame}/play"}> + <.icon name="hero-play" /> Play (opens trainer) + <.button variant="primary" navigate={~p"/admin/endgames/#{@endgame}/edit?return_to=show"}> <.icon name="hero-pencil-square" /> Edit endgame diff --git a/lib/chesstrainer_web/router.ex b/lib/chesstrainer_web/router.ex index 59fb058..beb9017 100644 --- a/lib/chesstrainer_web/router.ex +++ b/lib/chesstrainer_web/router.ex @@ -20,6 +20,9 @@ defmodule ChesstrainerWeb.Router do get "/", PageController, :home live "/chess-test", ChessTestLive + + live "/endgames", EndgameLive.PlayerIndex, :index + live "/endgames/:id/play", EndgameLive.Play, :play end scope "/admin", ChesstrainerWeb do diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 179b8af..ab718a6 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -32,7 +32,7 @@ Repo.insert_all(Endgame, [ }, %{ id: Ecto.UUID.generate(), - fen: "6k1/5p2/6p1/8/7p/8/6PP/6K1 b - - 0 0", + fen: "6k1/5p2/6p1/8/7p/8/6PP/6K1 b - - 0 1", color: :black, key: "KPPP v KPP", message: "3 pawns vs 2 pawns", diff --git a/test/chesstrainer/cache_test.exs b/test/chesstrainer/cache_test.exs new file mode 100644 index 0000000..1de00d8 --- /dev/null +++ b/test/chesstrainer/cache_test.exs @@ -0,0 +1,39 @@ +defmodule Chesstrainer.CacheTest do + use ExUnit.Case, async: true + + alias Chesstrainer.Cache + + setup do + table = :"test_#{System.unique_integer([:positive])}" + {:ok, pid} = Cache.start_link(table) + on_exit(fn -> if Process.alive?(pid), do: GenServer.stop(pid) end) + {:ok, %{pid: pid, table: table}} + end + + describe "ChessTrainer.Cache function tests" do + test "cache is started", %{pid: pid, table: table} do + assert is_pid(pid) + assert 0 = Cache.size(table) + end + + test "put/3", %{table: table} do + assert :ok = Cache.put(table, "key1", "value1") + assert {:ok, "value1"} = Cache.get(table, "key1") + assert 1 = Cache.size(table) + + assert :ok = Cache.put(table, "key2", "value2") + assert {:ok, "value2"} = Cache.get(table, "key2") + assert 2 = Cache.size(table) + end + + test "delete/2", %{table: table} do + assert :ok = Cache.put(table, "key1", "value1") + assert :ok = Cache.put(table, "key2", "value2") + assert 2 = Cache.size(table) + + assert :ok = Cache.delete(table, "key1") + assert 1 = Cache.size(table) + assert :error = Cache.get(table, "key1") + end + end +end diff --git a/test/chesstrainer/endgames_test.exs b/test/chesstrainer/endgames_test.exs new file mode 100644 index 0000000..bf7716d --- /dev/null +++ b/test/chesstrainer/endgames_test.exs @@ -0,0 +1,88 @@ +defmodule Chesstrainer.EndgamesTest do + use Chesstrainer.DataCase + + alias Chesstrainer.Endgames + alias Chesstrainer.Endgames.Endgame + + describe "create_endgame/1 FEN validation" do + test "accepts a standard valid FEN" do + attrs = %{ + fen: "8/8/3k4/8/8/3K1R2/8/8 w - - 0 1", + color: :white, + key: "KR v K", + result: :win, + rating: 1500 + } + + assert {:ok, %Endgame{}} = Endgames.create_endgame(attrs) + end + + test "normalizes fullmove 0 to 1 before inserting" do + attrs = %{ + fen: "6k1/5p2/6p1/8/7p/8/6PP/6K1 b - - 0 0", + color: :black, + key: "KPPP v KPP", + result: :win, + rating: 1500 + } + + assert {:ok, endgame} = Endgames.create_endgame(attrs) + assert endgame.fen == "6k1/5p2/6p1/8/7p/8/6PP/6K1 b - - 0 1" + end + + test "trims whitespace around the FEN before inserting" do + attrs = %{ + fen: " 8/8/3k4/8/8/3K1R2/8/8 w - - 0 1\t\n", + color: :white, + key: "KR v K", + result: :win, + rating: 1500 + } + + assert {:ok, endgame} = Endgames.create_endgame(attrs) + assert endgame.fen == "8/8/3k4/8/8/3K1R2/8/8 w - - 0 1" + end + + test "rejects a FEN with a zero digit in a rank" do + attrs = %{ + fen: "4k3/8/8/8/8/8/8/0K7 w - - 0 1", + color: :white, + key: "KQ v K", + result: :win, + rating: 1500 + } + + assert {:error, changeset} = Endgames.create_endgame(attrs) + assert [msg] = errors_on(changeset).fen + assert msg =~ "board must be 8 ranks" + end + + test "rejects a FEN with the wrong number of fields" do + attrs = %{ + fen: "8/8/3k4/8/8/3K1R2/8/8", + color: :white, + key: "KQ v K", + result: :win, + rating: 1500 + } + + assert {:error, changeset} = Endgames.create_endgame(attrs) + assert [msg] = errors_on(changeset).fen + assert msg =~ "6 space-separated" + end + + test "rejects a FEN with an unknown active color" do + attrs = %{ + fen: "8/8/3k4/8/8/3K1R2/8/8 x - - 0 1", + color: :white, + key: "KQ v K", + result: :win, + rating: 1500 + } + + assert {:error, changeset} = Endgames.create_endgame(attrs) + assert [msg] = errors_on(changeset).fen + assert msg =~ "active color" + end + end +end diff --git a/test/chesstrainer/fen_test.exs b/test/chesstrainer/fen_test.exs new file mode 100644 index 0000000..d61ffcd --- /dev/null +++ b/test/chesstrainer/fen_test.exs @@ -0,0 +1,62 @@ +defmodule Chesstrainer.FENTest do + use ExUnit.Case, async: true + + alias Chesstrainer.FEN + + describe "color_from_fen/1" do + test "extracts white from the active-color field" do + assert FEN.color_from_fen("8/8/3k4/8/8/3K1R2/8/8 w - - 0 1") == :white + end + + test "extracts black from the active-color field" do + assert FEN.color_from_fen("6k1/5p2/6p1/8/7p/8/6PP/6K1 b - - 0 1") == :black + end + + test "returns nil when the active-color field is missing" do + assert FEN.color_from_fen("8/8/8/8/8/8/8/8") == nil + end + end + + describe "normalize/1" do + test "trims surrounding whitespace" do + fen = "8/8/3k4/8/8/3K1R2/8/8 w - - 0 1" + assert FEN.normalize(" \n" <> fen <> "\t\n") == fen + end + + test "rewrites fullmove 0 to 1" do + fen = "6k1/5p2/6p1/8/7p/8/6PP/6K1 b - - 0 0" + assert FEN.normalize(fen) == "6k1/5p2/6p1/8/7p/8/6PP/6K1 b - - 0 1" + end + + test "rewrites fullmove 0 even when the FEN has surrounding whitespace" do + fen = "\n 6k1/5p2/6p1/8/7p/8/6PP/6K1 b - - 0 0 \t" + assert FEN.normalize(fen) == "6k1/5p2/6p1/8/7p/8/6PP/6K1 b - - 0 1" + end + + test "leaves a non-zero fullmove untouched" do + fen = "8/8/3k4/8/8/3K1R2/8/8 w - - 0 5" + assert FEN.normalize(fen) == fen + end + + test "leaves a standard starting position untouched" do + fen = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" + assert FEN.normalize(fen) == fen + end + + test "passes through a non-FEN string unchanged" do + assert FEN.normalize("not a fen") == "not a fen" + end + + test "passes through a partial FEN (fewer than 6 fields) unchanged" do + fen = "8/8/3k4/8/8/3K1R2/8/8 w - -" + assert FEN.normalize(fen) == fen + end + + test "passes through a FEN with a leading-zero fullmove unchanged" do + # Fullmove 0 only matches the literal "0"; leading zeros stay so the + # validator can surface them rather than silently masking the typo. + fen = "8/8/3k4/8/8/3K1R2/8/8 w - - 0 00" + assert FEN.normalize(fen) == fen + end + end +end diff --git a/test/chesstrainer_web/live/endgame_live/play_test.exs b/test/chesstrainer_web/live/endgame_live/play_test.exs new file mode 100644 index 0000000..b55951a --- /dev/null +++ b/test/chesstrainer_web/live/endgame_live/play_test.exs @@ -0,0 +1,115 @@ +defmodule ChesstrainerWeb.EndgameLive.PlayTest do + use ChesstrainerWeb.ConnCase + + import Phoenix.LiveViewTest + import Chesstrainer.EndgamesFixtures + + defp assert_init_board(view, expected_fen, expected_orientation, expected_player_color) do + %{proxy: {ref, _topic, _}} = view + + assert_receive {^ref, {:push_event, "init_board", payload}}, 100 + + assert payload.fen == expected_fen + assert payload.orientation == expected_orientation + assert payload.player_color == expected_player_color + end + + describe "mount" do + test "renders the chessboard hook div and pushes init_board", %{conn: conn} do + endgame = endgame_fixture(color: :white) + + {:ok, view, _html} = live(conn, ~p"/endgames/#{endgame}/play") + + assert has_element?(view, "#endgame-board") + assert_init_board(view, endgame.fen, "white", "white") + end + + test "pushes black orientation when the endgame color is black", %{conn: conn} do + endgame = endgame_fixture(color: :black) + + {:ok, view, _html} = live(conn, ~p"/endgames/#{endgame}/play") + + assert_init_board(view, endgame.fen, "black", "black") + end + + test "shows endgame metadata in the header", %{conn: conn} do + endgame = endgame_fixture(key: "KQ v K", color: :white, result: :win, rating: 1800) + + {:ok, _view, html} = live(conn, ~p"/endgames/#{endgame}/play") + + assert html =~ "KQ v K" + assert html =~ "white to move" + assert html =~ "win" + assert html =~ "1800" + end + + test "raises when the endgame does not exist", %{conn: conn} do + assert_raise Ecto.NoResultsError, fn -> + live(conn, ~p"/endgames/00000000-0000-0000-0000-000000000000/play") + end + end + end + + describe "chess_fen_invalid" do + test "shows a flash when the chessground hook reports an unparseable FEN", %{conn: conn} do + endgame = endgame_fixture() + + {:ok, view, _html} = live(conn, ~p"/endgames/#{endgame}/play") + + view + |> render_hook("chess_fen_invalid", %{ + "fen" => endgame.fen, + "message" => "Invalid FEN: move number must be a positive integer" + }) + + html = render(view) + assert html =~ "Endgame FEN is invalid" + assert html =~ "move number must be a positive integer" + end + end + + describe "endgame_move_played" do + test "appends the SAN move and updates last_san / last_from_to", %{conn: conn} do + endgame = endgame_fixture() + + {:ok, view, _html} = live(conn, ~p"/endgames/#{endgame}/play") + + view + |> render_hook("endgame_move_played", %{ + "from" => "d1", + "to" => "d7", + "san" => "Qd7+", + "fen" => endgame.fen, + "endgame_id" => endgame.id + }) + + html = render(view) + assert html =~ "Qd7+" + assert html =~ "d1 ➔ d7" + end + end + + describe "reset" do + test "clears move list and re-pushes init_board", %{conn: conn} do + endgame = endgame_fixture() + + {:ok, view, _html} = live(conn, ~p"/endgames/#{endgame}/play") + + view + |> render_hook("endgame_move_played", %{ + "from" => "d1", + "to" => "d7", + "san" => "Qd7+", + "fen" => endgame.fen, + "endgame_id" => endgame.id + }) + + assert render(view) =~ "Qd7+" + + view |> element("button", "Reset") |> render_click() + + assert render(view) =~ "No Moves" + assert_init_board(view, endgame.fen, "white", "white") + end + end +end diff --git a/test/chesstrainer_web/live/endgame_live/player_index_test.exs b/test/chesstrainer_web/live/endgame_live/player_index_test.exs new file mode 100644 index 0000000..848ebae --- /dev/null +++ b/test/chesstrainer_web/live/endgame_live/player_index_test.exs @@ -0,0 +1,28 @@ +defmodule ChesstrainerWeb.EndgameLive.PlayerIndexTest do + use ChesstrainerWeb.ConnCase + + import Phoenix.LiveViewTest + import Chesstrainer.EndgamesFixtures + + test "lists endgames with a play link per row", %{conn: conn} do + endgame = endgame_fixture() + other = endgame_fixture() + + {:ok, _view, html} = live(conn, ~p"/endgames") + + assert html =~ "Endgames" + assert html =~ endgame.key + assert html =~ other.key + assert html =~ ~p"/endgames/#{endgame}/play" + assert html =~ ~p"/endgames/#{other}/play" + end + + test "navigating to a play page renders the board", %{conn: conn} do + endgame = endgame_fixture() + + {:ok, view, _html} = live(conn, ~p"/endgames/#{endgame}/play") + + assert has_element?(view, "#endgame-board") + assert has_element?(view, ~s{a[href="/endgames"]}) + end +end diff --git a/test/support/fixtures/endgame_fixtures.ex b/test/support/fixtures/endgame_fixtures.ex new file mode 100644 index 0000000..0876a5c --- /dev/null +++ b/test/support/fixtures/endgame_fixtures.ex @@ -0,0 +1,44 @@ +defmodule Chesstrainer.EndgamesFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `Chesstrainer.Endgames` context. + """ + + @doc """ + Generate a unique endgame FEN. + + Endgames have a unique constraint on :fen, so tests need + distinct FENs when inserting more than one. Each call + shifts the white king's file to avoid FEN collisions. + """ + def unique_endgame_fen do + # White king on rank 1 in columns b..g, leaving 1-6 empty squares + # before and 6-1 after. Both before_king and after_king are valid + # single digits (1-8), and the rank sums to 8. + offset = System.unique_integer([:positive]) |> rem(6) + before_king = offset + 1 + after_king = 7 - before_king + "4k3/8/8/8/8/8/8/#{before_king}K#{after_king} w - - 0 1" + end + + @doc """ + Generate an endgame. + + A simple K v K position with a unique FEN, color, + key, rating, and result. + """ + def endgame_fixture(attrs \\ %{}) do + {:ok, endgame} = + attrs + |> Enum.into(%{ + fen: unique_endgame_fen(), + key: "KQ v K", + color: :white, + result: :win, + rating: 1500 + }) + |> Chesstrainer.Endgames.create_endgame() + + endgame + end +end