mirror of
https://github.com/TheRealOwenRees/chess-endgame-trainer.git
synced 2026-09-19 11:03:52 +00:00
create endgame play section
This commit is contained in:
@@ -4,11 +4,70 @@ import { Chess } from "chess.js";
|
|||||||
export const ChessBoard = {
|
export const ChessBoard = {
|
||||||
mounted() {
|
mounted() {
|
||||||
this.chess = new Chess();
|
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, {
|
this.ground = Chessground(this.el, {
|
||||||
fen: this.chess.fen(),
|
fen: fen,
|
||||||
|
orientation: orientation,
|
||||||
|
turnColor: turnColor,
|
||||||
movable: {
|
movable: {
|
||||||
color: "white",
|
color: player_color,
|
||||||
free: false,
|
free: false,
|
||||||
dests: this.getValidDests(),
|
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() {
|
getValidDests() {
|
||||||
@@ -56,7 +95,7 @@ export const ChessBoard = {
|
|||||||
const destRank = dest[1];
|
const destRank = dest[1];
|
||||||
if (piece?.role === "pawn" && (destRank === "1" || destRank === "8")) {
|
if (piece?.role === "pawn" && (destRank === "1" || destRank === "8")) {
|
||||||
this.showPromotionDialog(orig, dest);
|
this.showPromotionDialog(orig, dest);
|
||||||
return; // don't execute yet — wait for user choice
|
return;
|
||||||
}
|
}
|
||||||
this.executeMove(orig, dest);
|
this.executeMove(orig, dest);
|
||||||
},
|
},
|
||||||
@@ -98,12 +137,23 @@ export const ChessBoard = {
|
|||||||
const move = this.chess.move({ from: orig, to: dest, promotion });
|
const move = this.chess.move({ from: orig, to: dest, promotion });
|
||||||
|
|
||||||
if (move) {
|
if (move) {
|
||||||
this.pushEvent("move_played", {
|
const eventName =
|
||||||
|
this.el.dataset && this.el.dataset.endgameId
|
||||||
|
? "endgame_move_played"
|
||||||
|
: "move_played";
|
||||||
|
|
||||||
|
const payload = {
|
||||||
from: orig,
|
from: orig,
|
||||||
to: dest,
|
to: dest,
|
||||||
fen: this.chess.fen(),
|
fen: this.chess.fen(),
|
||||||
san: move.san,
|
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";
|
const nextTurnColor = this.chess.turn() === "w" ? "white" : "black";
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,78 @@ defmodule Chesstrainer.Endgames.Endgame do
|
|||||||
|> validate_format(:key, ~r/^(?=.{5,10}$)KQ*R*[NB]*P*\sv\sKQ*R*[NB]*P*$/,
|
|> 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"
|
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")
|
|> unique_constraint(:fen, message: "FEN already exists")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# 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
|
end
|
||||||
|
|||||||
@@ -101,4 +101,23 @@ defmodule Chesstrainer.Tags do
|
|||||||
def change_tag(%Tag{} = tag, attrs \\ %{}) do
|
def change_tag(%Tag{} = tag, attrs \\ %{}) do
|
||||||
Tag.changeset(tag, attrs)
|
Tag.changeset(tag, attrs)
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ defmodule ChesstrainerWeb.EndgameLive.Index do
|
|||||||
<div class="sr-only">
|
<div class="sr-only">
|
||||||
<.link navigate={~p"/admin/endgames/#{endgame}"}>Show</.link>
|
<.link navigate={~p"/admin/endgames/#{endgame}"}>Show</.link>
|
||||||
</div>
|
</div>
|
||||||
|
<.link navigate={~p"/endgames/#{endgame}/play"}>Play</.link>
|
||||||
<.link navigate={~p"/admin/endgames/#{endgame}/edit"}>Edit</.link>
|
<.link navigate={~p"/admin/endgames/#{endgame}/edit"}>Edit</.link>
|
||||||
</:action>
|
</:action>
|
||||||
<:action :let={{id, endgame}}>
|
<:action :let={{id, endgame}}>
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
defmodule ChesstrainerWeb.EndgameLive.Play do
|
||||||
|
use ChesstrainerWeb, :live_view
|
||||||
|
|
||||||
|
alias Chesstrainer.Endgames
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<Layouts.app flash={@flash}>
|
||||||
|
<.header>
|
||||||
|
Play endgame {@endgame.key}
|
||||||
|
<:subtitle>
|
||||||
|
{@endgame.color} to move · Result: {@endgame.result} · Rating: {@endgame.rating}
|
||||||
|
</:subtitle>
|
||||||
|
<:actions>
|
||||||
|
<.button navigate={~p"/endgames"}>
|
||||||
|
<.icon name="hero-arrow-left" /> Back to endgames
|
||||||
|
</.button>
|
||||||
|
</:actions>
|
||||||
|
</.header>
|
||||||
|
|
||||||
|
<div class="bg-gray-100 p-4 rounded mb-4 flex justify-between items-center shadow-sm">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600 font-semibold">Last Played Move:</p>
|
||||||
|
<p class="text-lg font-mono text-blue-600">{@last_from_to}</p>
|
||||||
|
<p class="text-lg font-mono text-blue-600">{@last_san}</p>
|
||||||
|
<p class="text-lg font-mono text-emerald-600">
|
||||||
|
{if @move_list == [] do
|
||||||
|
"No Moves"
|
||||||
|
else
|
||||||
|
Enum.reverse(@move_list) |> Enum.join(", ")
|
||||||
|
end}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<.button phx-click="reset" variant="primary">
|
||||||
|
<.icon name="hero-arrow-path" /> Reset
|
||||||
|
</.button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-center bg-gray-200 p-4 rounded-xl shadow-inner">
|
||||||
|
<div
|
||||||
|
id="endgame-board"
|
||||||
|
phx-hook="ChessBoard"
|
||||||
|
phx-update="ignore"
|
||||||
|
data-endgame-id={@endgame.id}
|
||||||
|
class="w-[400px] h-[400px]"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<.list>
|
||||||
|
<:item title="Fen">{@endgame.fen}</:item>
|
||||||
|
<:item title="Key">{@endgame.key}</:item>
|
||||||
|
<:item title="Color">{@endgame.color}</:item>
|
||||||
|
<:item title="Result">{@endgame.result}</:item>
|
||||||
|
<:item title="Rating">{@endgame.rating}</:item>
|
||||||
|
</.list>
|
||||||
|
</Layouts.app>
|
||||||
|
"""
|
||||||
|
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
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
defmodule ChesstrainerWeb.EndgameLive.PlayerIndex do
|
||||||
|
use ChesstrainerWeb, :live_view
|
||||||
|
|
||||||
|
alias Chesstrainer.Endgames
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<Layouts.app flash={@flash}>
|
||||||
|
<.header>
|
||||||
|
Endgames
|
||||||
|
<:subtitle>Pick an endgame to play.</:subtitle>
|
||||||
|
</.header>
|
||||||
|
|
||||||
|
<.table
|
||||||
|
id="player-endgames"
|
||||||
|
rows={@streams.endgames}
|
||||||
|
>
|
||||||
|
<:col :let={{_id, endgame}} label="Key">{endgame.key}</:col>
|
||||||
|
<:col :let={{_id, endgame}} label="Color">{endgame.color}</:col>
|
||||||
|
<:col :let={{_id, endgame}} label="Result">{endgame.result}</:col>
|
||||||
|
<:col :let={{_id, endgame}} label="Rating">{endgame.rating}</:col>
|
||||||
|
<:action :let={{_id, endgame}}>
|
||||||
|
<.link navigate={~p"/endgames/#{endgame}/play"}>Play</.link>
|
||||||
|
</:action>
|
||||||
|
</.table>
|
||||||
|
</Layouts.app>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def mount(_params, _session, socket) do
|
||||||
|
{:ok,
|
||||||
|
socket
|
||||||
|
|> assign(:page_title, "Endgames")
|
||||||
|
|> stream(:endgames, Endgames.list_endgames())}
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -31,6 +31,9 @@ defmodule ChesstrainerWeb.EndgameLive.Show do
|
|||||||
<.button navigate={~p"/admin/endgames"}>
|
<.button navigate={~p"/admin/endgames"}>
|
||||||
<.icon name="hero-arrow-left" />
|
<.icon name="hero-arrow-left" />
|
||||||
</.button>
|
</.button>
|
||||||
|
<.button navigate={~p"/endgames/#{@endgame}/play"}>
|
||||||
|
<.icon name="hero-play" /> Play (opens trainer)
|
||||||
|
</.button>
|
||||||
<.button variant="primary" navigate={~p"/admin/endgames/#{@endgame}/edit?return_to=show"}>
|
<.button variant="primary" navigate={~p"/admin/endgames/#{@endgame}/edit?return_to=show"}>
|
||||||
<.icon name="hero-pencil-square" /> Edit endgame
|
<.icon name="hero-pencil-square" /> Edit endgame
|
||||||
</.button>
|
</.button>
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ defmodule ChesstrainerWeb.Router do
|
|||||||
get "/", PageController, :home
|
get "/", PageController, :home
|
||||||
|
|
||||||
live "/chess-test", ChessTestLive
|
live "/chess-test", ChessTestLive
|
||||||
|
|
||||||
|
live "/endgames", EndgameLive.PlayerIndex, :index
|
||||||
|
live "/endgames/:id/play", EndgameLive.Play, :play
|
||||||
end
|
end
|
||||||
|
|
||||||
scope "/admin", ChesstrainerWeb do
|
scope "/admin", ChesstrainerWeb do
|
||||||
|
|||||||
+1
-1
@@ -32,7 +32,7 @@ Repo.insert_all(Endgame, [
|
|||||||
},
|
},
|
||||||
%{
|
%{
|
||||||
id: Ecto.UUID.generate(),
|
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,
|
color: :black,
|
||||||
key: "KPPP v KPP",
|
key: "KPPP v KPP",
|
||||||
message: "3 pawns vs 2 pawns",
|
message: "3 pawns vs 2 pawns",
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
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 "rejects a FEN with fullmove number zero" 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 {:error, changeset} = Endgames.create_endgame(attrs)
|
||||||
|
assert [msg] = errors_on(changeset).fen
|
||||||
|
assert msg =~ "fullmove number"
|
||||||
|
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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user