diff --git a/assets/css/_board.css b/assets/css/_board.css new file mode 100644 index 0000000..a5c5a60 --- /dev/null +++ b/assets/css/_board.css @@ -0,0 +1,7 @@ +.bg-boardwhite { + background-color: #f2f5f3; /* light square */ +} + +.bg-boardblack { + background-color: #71828f; /* dark square */ +} \ No newline at end of file diff --git a/assets/css/app.css b/assets/css/app.css index 3650ca2..0f151d4 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -8,6 +8,7 @@ @import "../node_modules/chessground/assets/chessground.brown.css"; @import "../node_modules/chessground/assets/chessground.cburnett.css"; +@import "./_board.css"; @import "./_promotion.css"; @source "../css"; diff --git a/lib/chesstrainer/application.ex b/lib/chesstrainer/application.ex index c79671b..65f5b77 100644 --- a/lib/chesstrainer/application.ex +++ b/lib/chesstrainer/application.ex @@ -15,7 +15,9 @@ defmodule Chesstrainer.Application do # Start a worker by calling: Chesstrainer.Worker.start_link(arg) # {Chesstrainer.Worker, arg}, # Start to serve requests, typically the last entry - ChesstrainerWeb.Endpoint + ChesstrainerWeb.Endpoint, + {Registry, keys: :unique, name: Chesstrainer.CacheRegistry}, + {Chesstrainer.Cache, :tablebase} ] # See https://elixir.hexdocs.pm/Supervisor.html diff --git a/lib/chesstrainer/cache.ex b/lib/chesstrainer/cache.ex new file mode 100644 index 0000000..d397652 --- /dev/null +++ b/lib/chesstrainer/cache.ex @@ -0,0 +1,101 @@ +defmodule Chesstrainer.Cache do + @moduledoc """ + Generic LRU cache. + + ## Examples + + iex> start_link(:tablebase) + """ + + use GenServer + + @max_size 1000 + @backup_interval_minutes 60 + + def start_link(table), do: GenServer.start_link(__MODULE__, table, name: via_tuple(table)) + def put(table, key, value), do: GenServer.call(via_tuple(table), {:put, key, value}) + def get(table, key), do: GenServer.call(via_tuple(table), {:get, key}) + def delete(table, key), do: GenServer.call(via_tuple(table), {:delete, key}) + def backup(table), do: GenServer.cast(via_tuple(table), :backup) + + def show(table) do + ensure_table(table) + :ets.tab2list(table) + end + + def size(table), do: :ets.info(table, :size) + + @impl true + def init(table) do + {:ok, table, {:continue, :restore_and_backup}} + end + + @impl true + def handle_continue(:restore_and_backup, table) do + create_table(table) + __MODULE__.Persist.restore(table) + :timer.send_interval(:timer.minutes(@backup_interval_minutes), :backup) + {:noreply, table} + end + + @impl true + def handle_call({:put, key, value}, _from, table) do + ensure_table(table) + timestamp = System.system_time(:millisecond) + :ets.insert(table, {key, value, timestamp}) + + GenServer.cast(self(), :maybe_evict) + + {:reply, :ok, table} + end + + @impl true + def handle_call({:get, key}, _from, table) do + ensure_table(table) + + case :ets.lookup(table, key) do + [{^key, value, _old_timestamp}] -> + timestamp = System.system_time(:millisecond) + :ets.insert(table, {key, value, timestamp}) + {:reply, {:ok, value}, table} + + [] -> + {:reply, :error, table} + end + end + + @impl true + def handle_call({:delete, key}, _from, table) do + :ets.delete(table, key) + {:reply, :ok, table} + end + + @impl true + def handle_cast(:maybe_evict, table) do + __MODULE__.Evict.maybe_evict_oldest(table, @max_size) + {:noreply, table} + end + + @impl true + def handle_cast(:backup, table) do + __MODULE__.Persist.backup(table) + {:noreply, table} + end + + @impl true + def handle_info(:backup, table) do + GenServer.cast(self(), :backup) + {:noreply, table} + end + + defp via_tuple(table), do: {:via, Registry, {Chesstrainer.CacheRegistry, table}} + + defp ensure_table(table) do + case :ets.whereis(table) do + :undefined -> create_table(table) + _ -> :ok + end + end + + defp create_table(table), do: :ets.new(table, [:named_table, :public, :set]) +end diff --git a/lib/chesstrainer/cache/evict.ex b/lib/chesstrainer/cache/evict.ex new file mode 100644 index 0000000..a6dfd5a --- /dev/null +++ b/lib/chesstrainer/cache/evict.ex @@ -0,0 +1,29 @@ +defmodule Chesstrainer.Cache.Evict do + @moduledoc """ + Eviction functions for LRU cache + """ + + # O(n) time, not efficient + def maybe_evict_oldest(table, max_size) do + if :ets.info(table, :size) > max_size do + case find_oldest_entry(table) do + {oldest_key, _} -> :ets.delete(table, oldest_key) + _ -> :ok + end + end + end + + defp find_oldest_entry(table) do + :ets.foldl( + fn {k, _v, t}, acc -> + case acc do + nil -> {k, t} + {_, oldest_t} when t < oldest_t -> {k, t} + _ -> acc + end + end, + nil, + table + ) + end +end diff --git a/lib/chesstrainer/cache/persist.ex b/lib/chesstrainer/cache/persist.ex new file mode 100644 index 0000000..dbed95f --- /dev/null +++ b/lib/chesstrainer/cache/persist.ex @@ -0,0 +1,29 @@ +defmodule Chesstrainer.Cache.Persist do + @moduledoc """ + Persistance for cache. Backup and restore functions. + """ + + @backup_path "priv/static/backups" + + def backup(table) do + File.mkdir_p!(@backup_path) + filename = Path.join(@backup_path, "#{table}.det") |> String.to_charlist() + {:ok, dets_table} = :dets.open_file(filename, type: :set) + :ets.to_dets(table, dets_table) + :dets.sync(dets_table) + end + + def restore(table) do + File.mkdir_p!(@backup_path) + filename = Path.join(@backup_path, "#{table}.det") |> String.to_charlist() + + case :dets.open_file(table, file: filename, type: :set) do + {:ok, ^table} -> + :dets.foldl(fn entry, _ -> :ets.insert(table, entry) end, :ok, table) + :dets.close(table) + + {:error, _} -> + :ok + end + end +end diff --git a/lib/chesstrainer/endgames.ex b/lib/chesstrainer/endgames.ex new file mode 100644 index 0000000..510f332 --- /dev/null +++ b/lib/chesstrainer/endgames.ex @@ -0,0 +1,123 @@ +defmodule Chesstrainer.Endgames do + @moduledoc """ + The Endgames context. + """ + + import Ecto.Query, warn: false + alias Chesstrainer.Repo + + alias Chesstrainer.Endgames.Endgame + + @doc """ + Returns the list of endgames. + + ## Examples + + iex> list_endgames() + [%Endgame{}, ...] + + """ + def list_endgames do + Repo.all(Endgame) + end + + @doc """ + Gets a single endgame. + + Raises `Ecto.NoResultsError` if the Endgame does not exist. + + ## Examples + + iex> get_endgame!(123) + %Endgame{} + + iex> get_endgame!(456) + ** (Ecto.NoResultsError) + + """ + def get_endgame!(id), do: Repo.get!(Endgame, id) + + @doc """ + Creates a endgame. + + ## Examples + + iex> create_endgame(%{field: value}) + {:ok, %Endgame{}} + + iex> create_endgame(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_endgame(attrs) do + %Endgame{} + |> Endgame.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a endgame. + + ## Examples + + iex> update_endgame(endgame, %{field: new_value}) + {:ok, %Endgame{}} + + iex> update_endgame(endgame, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_endgame(%Endgame{} = endgame, attrs) do + # ignore fen on update + attrs = Map.delete(attrs, "fen") + + endgame + |> Endgame.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a endgame. + + ## Examples + + iex> delete_endgame(endgame) + {:ok, %Endgame{}} + + iex> delete_endgame(endgame) + {:error, %Ecto.Changeset{}} + + """ + def delete_endgame(%Endgame{} = endgame) do + Repo.delete(endgame) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking endgame changes. + + ## Examples + + iex> change_endgame(endgame) + %Ecto.Changeset{data: %Endgame{}} + + """ + def change_endgame(%Endgame{} = endgame, attrs \\ %{}) do + Endgame.changeset(endgame, attrs) + end + + def add_tag(endgame, tag) do + endgame + |> Repo.preload(:tags) + |> Ecto.Changeset.change() + |> Ecto.Changeset.put_assoc(:tags, [tag | endgame.tags]) + |> Repo.update() + end + + def remove_tag(endgame, tag) do + endgame + |> Repo.preload(:tags) + |> Ecto.Changeset.change() + |> Ecto.Changeset.put_assoc(:tags, endgame.tags -- [tag]) + |> Repo.update() + end +end diff --git a/lib/chesstrainer/endgames/endgame.ex b/lib/chesstrainer/endgames/endgame.ex new file mode 100644 index 0000000..65a79d9 --- /dev/null +++ b/lib/chesstrainer/endgames/endgame.ex @@ -0,0 +1,41 @@ +defmodule Chesstrainer.Endgames.Endgame do + @moduledoc """ + A singular endgame entry + """ + + use Ecto.Schema + import Ecto.Changeset + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + schema "endgames" do + field :fen, :string + field :color, Ecto.Enum, values: [:white, :black] + field :key, :string + field :message, :string + field :notes, :string + field :result, Ecto.Enum, values: [:win, :loss, :draw] + field :rating, :integer + field :rating_deviation, :integer + field :times_attempted, :integer + field :times_solved, :integer + + many_to_many :tags, Chesstrainer.Tags.Tag, + join_through: "endgames_tags", + on_replace: :delete + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(endgame, attrs) do + endgame + |> cast(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" + ) + |> unique_constraint(:fen, message: "FEN already exists") + end +end diff --git a/lib/chesstrainer/endgames/tablebase.ex b/lib/chesstrainer/endgames/tablebase.ex new file mode 100644 index 0000000..d735085 --- /dev/null +++ b/lib/chesstrainer/endgames/tablebase.ex @@ -0,0 +1,57 @@ +defmodule Chesstrainer.Endgames.Tablebase do + @moduledoc """ + All endgame tablebase related functions. + """ + + alias Chesstrainer.Endgames.Tablebase + + @type result :: :win | :draw | :loss | :unknown + + @type t :: %__MODULE__{ + category: result, + checkmate: boolean(), + dtc: integer() | nil, + dtm: integer() | nil, + dtw: integer() | nil, + dtz: integer() | nil, + insufficient_material: boolean(), + moves: [Tablebase.Move.t()] + } + + defstruct category: nil, + checkmate: false, + dtc: nil, + dtm: nil, + dtw: nil, + dtz: nil, + insufficient_material: false, + moves: nil + + @spec new(String.t()) :: + {:ok, t()} + | {:cooldown, non_neg_integer()} + | {:error, :invalid_endgame} + | {:error, term()} + def new(fen) do + case Tablebase.Cache.get(fen) do + {_fen, tablebase} -> + {:cache, tablebase} + + _ -> + case Tablebase.Parser.from_fen(fen) do + {:error, reason} -> + {:error, reason} + + {:cooldown, remaining_ms} -> + {:cooldown, remaining_ms} + + {:ok, tablebase} when tablebase.category == :unknown -> + {:error, :invalid_endgame} + + {:ok, tablebase} -> + Tablebase.Cache.put(fen, tablebase) + {:ok, tablebase} + end + end + end +end diff --git a/lib/chesstrainer/endgames/tablebase/cache.ex b/lib/chesstrainer/endgames/tablebase/cache.ex new file mode 100644 index 0000000..8214ad3 --- /dev/null +++ b/lib/chesstrainer/endgames/tablebase/cache.ex @@ -0,0 +1,17 @@ +defmodule Chesstrainer.Endgames.Tablebase.Cache do + @moduledoc """ + A cache of tablebases by FEN. + """ + + alias Chesstrainer.Cache + + @table :tablebase + + def put(fen, tablebase), do: Cache.put(@table, fen, tablebase) + + def get(fen), do: Cache.get(@table, fen) + + def delete(fen), do: Cache.delete(@table, fen) + def show, do: Cache.show(@table) + def size, do: Cache.size(@table) +end diff --git a/lib/chesstrainer/endgames/tablebase/move.ex b/lib/chesstrainer/endgames/tablebase/move.ex new file mode 100644 index 0000000..da42760 --- /dev/null +++ b/lib/chesstrainer/endgames/tablebase/move.ex @@ -0,0 +1,41 @@ +defmodule Chesstrainer.Endgames.Tablebase.Move do + @moduledoc """ + Moves struct for the tablebase + """ + + @type result :: :win | :draw | :loss + + @type t :: %__MODULE__{ + category: result, + checkmate: boolean(), + conversion: boolean(), + dtc: integer() | nil, + dtm: integer() | nil, + dtw: integer() | nil, + dtz: integer() | nil, + precise_dtz: integer() | nil, + san: String.t() | nil, + stalemate: boolean(), + uci: String.t() | nil, + variant_loss: boolean(), + variant_win: boolean(), + zeroing: boolean(), + insufficient_material: boolean() + } + + defstruct category: nil, + checkmate: false, + conversion: false, + dtc: nil, + dtm: nil, + dtw: nil, + dtz: nil, + precise_dtz: nil, + san: nil, + stalemate: false, + uci: nil, + variant_loss: false, + variant_win: false, + zeroing: false, + insufficient_material: false +end diff --git a/lib/chesstrainer/endgames/tablebase/parser.ex b/lib/chesstrainer/endgames/tablebase/parser.ex new file mode 100644 index 0000000..8f1af3c --- /dev/null +++ b/lib/chesstrainer/endgames/tablebase/parser.ex @@ -0,0 +1,106 @@ +defmodule Chesstrainer.Endgames.Tablebase.Parser do + @moduledoc """ + Fetch and parse tablebase data to fit the tablebase struct + + Lichess tablebase response from FEN string. + https://lichess.org/api#tag/tablebase + + FEN must have spaces replaced by underscores to satisfy the Lichess API. + """ + + import Chesstrainer.Ratelimiters + alias Chesstrainer.Endgames.Tablebase + + @lichess_tablebase_url "http://tablebase.lichess.ovh/standard?fen=" + + @spec from_fen(String.t()) :: + {:ok, Tablebase.t()} | {:cooldown, non_neg_integer()} | {:error, term()} + def from_fen(fen) do + case lichess_check_cooldown() do + {:ok, 0} -> tablebase_response(fen) + {:cooldown, remaining_ms} -> {:cooldown, remaining_ms} + end + end + + @spec tablebase_response(String.t()) :: {:ok, Tablebase.t()} | {:error, term()} + defp tablebase_response(fen) do + response = + fen + |> sanitise_fen() + |> build_request_string() + |> Req.get() + |> parse_response() + + case response do + {:ok, body} -> from_map(body) + {:error, reason} -> {:error, reason} + end + end + + # exposed for testing + def from_map(map) do + {:ok, + %Tablebase{ + category: map["category"] |> String.to_atom(), + checkmate: map["checkmate"], + dtc: map["dtc"], + dtm: map["dtm"], + dtw: map["dtw"], + dtz: map["dtz"], + insufficient_material: map["insufficient_material"], + moves: Enum.map(map["moves"], &moves_from_map/1) + }} + end + + defp moves_from_map(map) do + %Tablebase.Move{ + category: map["category"] |> String.to_atom(), + checkmate: map["checkmate"], + conversion: map["conversion"], + dtc: map["dtc"], + dtm: map["dtm"], + dtw: map["dtw"], + dtz: map["dtz"], + insufficient_material: map["insufficient_material"], + precise_dtz: map["precise_dtz"], + san: map["san"], + stalemate: map["stalemate"], + uci: map["uci"], + variant_loss: map["variant_loss"], + variant_win: map["variant_win"], + zeroing: map["zeroing"] + } + end + + defp parse_response(response) do + case response do + # Success + {:ok, %Req.Response{status: 200, body: body}} -> + {:ok, body} + + # Client errors + {:ok, %Req.Response{status: status, body: body}} when status in 400..499 -> + {:error, {:client_error, status, body}} + + # Rate limiting + {:ok, %Req.Response{status: 429, body: body}} -> + lichess_add_cooldown() + {:error, {:too_many_requests, 429, body}} + + # Server errors + {:ok, %Req.Response{status: status, body: body}} when status in 500..599 -> + {:error, {:server_error, status, body}} + + # Catch‑all for other status codes + {:ok, %Req.Response{status: status, body: body}} -> + {:error, {:http_error, status, body}} + + # Network / client‑side failure + {:error, reason} -> + {:error, reason} + end + end + + defp sanitise_fen(fen), do: String.replace(fen, " ", "_") + defp build_request_string(fen), do: "#{@lichess_tablebase_url}#{fen}" +end diff --git a/lib/chesstrainer/fen.ex b/lib/chesstrainer/fen.ex new file mode 100644 index 0000000..b23b003 --- /dev/null +++ b/lib/chesstrainer/fen.ex @@ -0,0 +1,13 @@ +defmodule Chesstrainer.FEN do + @spec color_from_fen(String.t()) :: :white | :black | nil + def color_from_fen(fen) do + fen + |> String.split(" ") + |> Enum.at(1) + |> color_initial_to_color_atom() + 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 +end diff --git a/lib/chesstrainer/game.ex b/lib/chesstrainer/game.ex new file mode 100644 index 0000000..d6ec4b6 --- /dev/null +++ b/lib/chesstrainer/game.ex @@ -0,0 +1,145 @@ +defmodule Chesstrainer.Game do + @moduledoc """ + Game related functions + """ + alias Chesstrainer.Endgames + + @type game_type :: :endgame + @type square :: {atom(), pos_integer()} + @type move :: {square, square} + @type colors :: :white | :black + @type game_state :: :continue | :loss | :draw + @type result :: :win | :draw | :loss + + @type t :: %__MODULE__{ + player_color: colors, + board: map(), + active_color: colors, + castling: list(), + en_passant: term() | nil, + moves: list(), + halfmove_clock: non_neg_integer(), + fullmove_clock: non_neg_integer(), + captures: list(), + check: term() | nil, + result: result, + pgn: String.t() | nil, + orientation: colors, + move_from_square: square, + move_to_square: square, + game_type: game_type, + fen: String.t(), + tablebase: term(), + game_state: game_state + } + + defstruct board: %{}, + player_color: :white, + active_color: :white, + castling: [], + en_passant: nil, + moves: [], + halfmove_clock: 0, + fullmove_clock: 0, + captures: [], + check: nil, + result: nil, + pgn: nil, + orientation: :white, + move_from_square: nil, + move_to_square: nil, + game_type: nil, + fen: nil, + tablebase: nil, + game_state: :continue + + @doc """ + Builds a `%ChessTrainer.Game{}` from a FEN string. + + On success, returns `{:ok, game}` with the parsed board state. + + If the FEN string is invalid, `Chex.Parser.FEN.parse/1` raises a + `MatchError`. This function rescues that exception and returns + `{:error, :invalid_fen, game}`, where `game` is a fallback position + containing only kings. + + This is a bad implementation in the Chex libary and should be fixed at our fork: + https://github.com/therealowenrees/chex + + We should return something like{:ok, game} | {:error | :invalid_fen} + + ## Examples + + iex> ChessTrainer.Game.game_from_fen("valid fen", :endgame) + {:ok, %ChessTrainer.Game{}} + + iex> ChessTrainer.Game.game_from_fen("invalid fen", :endgame) + {:error, :invalid_fen, %ChessTrainer.Game{}} + + """ + @dialyzer {:nowarn_function, game_from_fen: 2} + @spec game_from_fen(String.t(), game_type()) :: {:ok, t()} | {:error, :invalid_fen, t()} + def game_from_fen(fen, game_type) do + try do + {:ok, %Chex.Game{} = chex_game} = Chex.Parser.FEN.parse(fen) + + game = %__MODULE__{ + board: chex_game.board, + player_color: chex_game.active_color, + active_color: chex_game.active_color, + castling: chex_game.castling, + en_passant: chex_game.en_passant, + moves: chex_game.moves, + halfmove_clock: chex_game.halfmove_clock, + fullmove_clock: chex_game.fullmove_clock, + captures: chex_game.captures, + check: chex_game.check, + result: chex_game.result, + pgn: chex_game.pgn, + orientation: chex_game.active_color, + move_from_square: nil, + move_to_square: nil, + game_type: game_type, + fen: fen, + game_state: :continue, + tablebase: + if game_type === :endgame do + Endgames.Tablebase.new(fen) + else + nil + end + } + + {:ok, game} + rescue + # if a MatchError error is raised, then return a game with only kings on the board + MatchError -> + game = %__MODULE__{ + board: %{ + {:e, 1} => {:king, :white, {:e, 1}}, + {:e, 8} => {:king, :black, {:e, 8}} + }, + player_color: :white, + active_color: :white, + castling: [], + en_passant: nil, + moves: [], + halfmove_clock: 0, + fullmove_clock: 0, + captures: nil, + check: nil, + result: :draw, + pgn: nil, + orientation: :white, + move_from_square: nil, + move_to_square: nil, + game_type: :endgame, + fen: "4k3/8/8/8/8/8/8/4K3 w - - 0 1", + game_state: :draw, + tablebase: nil + } + + {:error, :invalid_fen, game} + end + end +end diff --git a/lib/chesstrainer/ratelimiters.ex b/lib/chesstrainer/ratelimiters.ex new file mode 100644 index 0000000..2fbd3bb --- /dev/null +++ b/lib/chesstrainer/ratelimiters.ex @@ -0,0 +1,48 @@ +defmodule Chesstrainer.Ratelimiters do + @moduledoc """ + Generic rate limiter for external APIs (e.g., Lichess). + + This module exposes convenience functions for each external API + that delegate to their specific rate limiter implementation. + + ## Examples + + iex> ChessTrainer.Ratelimiters.lichess_reset_cooldown() + true + + iex> ChessTrainer.Ratelimiters.lichess_check_cooldown() + {:ok, 0} + + iex> ChessTrainer.Ratelimiters.lichess_add_cooldown() + true + """ + + alias Chesstrainer.Ratelimiters.Lichess + + @doc """ + Adds a cooldown entry for the Lichess API. + + This sets the cooldown period (e.g. 60 seconds) starting from now. + Useful when a `429 Too Many Requests` response is received. + """ + @spec lichess_add_cooldown() :: boolean() + def lichess_add_cooldown, do: Lichess.add_cooldown() + + @doc """ + Resets the cooldown for the Lichess API. + + This clears any existing cooldown, allowing requests immediately. + """ + @spec lichess_reset_cooldown() :: boolean() + def lichess_reset_cooldown, do: Lichess.reset_cooldown() + + @doc """ + Checks whether the Lichess API is currently under cooldown. + + Returns: + * `{:ok, 0}` if no cooldown is active + * `{:cooldown, remaining_ms}` if still cooling down, with the remaining time in milliseconds + """ + @spec lichess_check_cooldown() :: {:ok, non_neg_integer} | {:cooldown, non_neg_integer} + def lichess_check_cooldown, do: Lichess.check_cooldown() +end diff --git a/lib/chesstrainer/ratelimiters/lichess.ex b/lib/chesstrainer/ratelimiters/lichess.ex new file mode 100644 index 0000000..a138ee5 --- /dev/null +++ b/lib/chesstrainer/ratelimiters/lichess.ex @@ -0,0 +1,55 @@ +defmodule Chesstrainer.Ratelimiters.Lichess do + @moduledoc """ + Global rate limiter for 429 response codes from the Lichess API. + """ + + @cooldown_seconds 60 + @table :lichess_cooldown + + def add_cooldown do + ensure_table() + insert_cooldown() + end + + def reset_cooldown do + ensure_table() + :ets.insert(@table, {:until, nil}) + end + + def check_cooldown do + ensure_table() + current_ms = System.system_time(:millisecond) + + case :ets.lookup(@table, :until) do + [{:until, nil}] -> + {:ok, 0} + + [{:until, until_ms}] -> + remaining = until_ms - current_ms + + if remaining <= 0 do + reset_cooldown() + {:ok, 0} + else + {:cooldown, remaining} + end + end + end + + defp create do + :ets.new(@table, [:named_table, :public, :set]) + :ets.insert(@table, {:until, nil}) + end + + defp ensure_table do + case :ets.whereis(@table) do + :undefined -> create() + _ -> :ok + end + end + + defp insert_cooldown do + until_ms = System.system_time(:millisecond) + @cooldown_seconds * 1000 + :ets.insert(@table, {:until, until_ms}) + end +end diff --git a/lib/chesstrainer/tags.ex b/lib/chesstrainer/tags.ex new file mode 100644 index 0000000..42c1efc --- /dev/null +++ b/lib/chesstrainer/tags.ex @@ -0,0 +1,104 @@ +defmodule Chesstrainer.Tags do + @moduledoc """ + The Tags context. + """ + + import Ecto.Query, warn: false + alias Chesstrainer.Repo + + alias Chesstrainer.Tags.Tag + + @doc """ + Returns the list of tags. + + ## Examples + + iex> list_tags() + [%Tag{}, ...] + + """ + def list_tags do + Repo.all(Tag) + end + + @doc """ + Gets a single tag. + + Raises `Ecto.NoResultsError` if the Tag does not exist. + + ## Examples + + iex> get_tag!(123) + %Tag{} + + iex> get_tag!(456) + ** (Ecto.NoResultsError) + + """ + def get_tag!(id), do: Repo.get!(Tag, id) + + @doc """ + Creates a tag. + + ## Examples + + iex> create_tag(%{field: value}) + {:ok, %Tag{}} + + iex> create_tag(%{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def create_tag(attrs) do + %Tag{} + |> Tag.changeset(attrs) + |> Repo.insert() + end + + @doc """ + Updates a tag. + + ## Examples + + iex> update_tag(tag, %{field: new_value}) + {:ok, %Tag{}} + + iex> update_tag(tag, %{field: bad_value}) + {:error, %Ecto.Changeset{}} + + """ + def update_tag(%Tag{} = tag, attrs) do + tag + |> Tag.changeset(attrs) + |> Repo.update() + end + + @doc """ + Deletes a tag. + + ## Examples + + iex> delete_tag(tag) + {:ok, %Tag{}} + + iex> delete_tag(tag) + {:error, %Ecto.Changeset{}} + + """ + def delete_tag(%Tag{} = tag) do + Repo.delete(tag) + end + + @doc """ + Returns an `%Ecto.Changeset{}` for tracking tag changes. + + ## Examples + + iex> change_tag(tag) + %Ecto.Changeset{data: %Tag{}} + + """ + def change_tag(%Tag{} = tag, attrs \\ %{}) do + Tag.changeset(tag, attrs) + end +end diff --git a/lib/chesstrainer/tags/tag.ex b/lib/chesstrainer/tags/tag.ex new file mode 100644 index 0000000..93fe811 --- /dev/null +++ b/lib/chesstrainer/tags/tag.ex @@ -0,0 +1,19 @@ +defmodule Chesstrainer.Tags.Tag do + use Ecto.Schema + import Ecto.Changeset + + schema "tags" do + field :name, :string + field :category, Ecto.Enum, values: [endgame: "endgame", opening: "opening", tactic: "tactic"] + + timestamps(type: :utc_datetime) + end + + @doc false + def changeset(tag, attrs) do + tag + |> cast(attrs, [:name, :category]) + |> validate_required([:name, :category]) + |> unique_constraint(:name) + end +end diff --git a/lib/chesstrainer_web/components/board/board_component.ex b/lib/chesstrainer_web/components/board/board_component.ex new file mode 100644 index 0000000..11483e9 --- /dev/null +++ b/lib/chesstrainer_web/components/board/board_component.ex @@ -0,0 +1,55 @@ +defmodule ChesstrainerWeb.Board.BoardComponent do + @moduledoc """ + Chess board component + """ + + use Phoenix.Component + import ChesstrainerWeb.Board.SquareComponent + + attr :game, :map, required: true + + attr :myself, :any, required: true + + def board(assigns) do + files_list = [:a, :b, :c, :d, :e, :f, :g, :h] + + assigns = + assigns + |> assign(:files_list, files_list) + |> assign( + :files, + case assigns.game.orientation do + :white -> files_list + :black -> Enum.reverse(files_list) + end + ) + |> assign( + :ranks, + case assigns.game.orientation do + :white -> Enum.reverse(1..8) + :black -> 1..8 + end + ) + + ~H""" +
+
+ <%= for rank <- @ranks do %> + <%= for file <- @files do %> + <% square = {file, rank} %> + <% piece = Map.get(@game.board, square) %> + <.square + file={file} + rank={rank} + piece={piece} + files_list={@files_list} + move_from_square={@game.move_from_square} + myself={@myself} + /> + <% end %> + <% end %> +
+
+ """ + end +end diff --git a/lib/chesstrainer_web/components/board/piece_component.ex b/lib/chesstrainer_web/components/board/piece_component.ex new file mode 100644 index 0000000..d4c9232 --- /dev/null +++ b/lib/chesstrainer_web/components/board/piece_component.ex @@ -0,0 +1,33 @@ +defmodule ChesstrainerWeb.Board.PieceComponent do + @moduledoc """ + Chess pieces component + """ + use Phoenix.Component + + attr :piece, :any, required: true + attr :class, :string, default: "" + + def piece(assigns) do + assigns = assign(assigns, :path, piece_path(assigns.piece)) + + ~H""" + <%= if @path do %> + + <% end %> + """ + end + + def piece_path({:king, :white, _square}), do: "/images/pieces/white/king.svg" + def piece_path({:queen, :white, _square}), do: "/images/pieces/white/queen.svg" + def piece_path({:rook, :white, _square}), do: "/images/pieces/white/rook.svg" + def piece_path({:bishop, :white, _square}), do: "/images/pieces/white/bishop.svg" + def piece_path({:knight, :white, _square}), do: "/images/pieces/white/knight.svg" + def piece_path({:pawn, :white, _square}), do: "/images/pieces/white/pawn.svg" + def piece_path({:king, :black, _square}), do: "/images/pieces/black/king.svg" + def piece_path({:queen, :black, _square}), do: "/images/pieces/black/queen.svg" + def piece_path({:rook, :black, _square}), do: "/images/pieces/black/rook.svg" + def piece_path({:bishop, :black, _square}), do: "/images/pieces/black/bishop.svg" + def piece_path({:knight, :black, _square}), do: "/images/pieces/black/knight.svg" + def piece_path({:pawn, :black, _square}), do: "/images/pieces/black/pawn.svg" + def piece_path(_), do: nil +end diff --git a/lib/chesstrainer_web/components/board/square_component.ex b/lib/chesstrainer_web/components/board/square_component.ex new file mode 100644 index 0000000..f538db8 --- /dev/null +++ b/lib/chesstrainer_web/components/board/square_component.ex @@ -0,0 +1,48 @@ +defmodule ChesstrainerWeb.Board.SquareComponent do + @moduledoc """ + Renders a single chess square with optional piece and highlighting. + """ + use Phoenix.Component + import ChesstrainerWeb.Board.PieceComponent + + attr :file, :atom, required: true + attr :rank, :integer, required: true + attr :piece, :any, default: nil + attr :files_list, :list, required: true + attr :move_from_square, :any, default: nil + attr :myself, :any, required: true + + def square(assigns) do + current_square = {assigns.file, assigns.rank} + + assigns = + assigns + |> assign(:highlight_square, current_square === assigns.move_from_square) + + ~H""" +
f == @file end), + @rank - 1 + ), + highlight_square(@highlight_square) + ]} + phx-click="square-click" + phx-value-file={@file} + phx-value-rank={@rank} + phx-value-type="move" + phx-target={@myself} + > + <.piece piece={@piece} class="w-10 h-10" /> +
+ """ + end + + def background(file_idx, rank_idx) when rem(file_idx + rank_idx, 2) != 0, do: "bg-boardwhite" + def background(_, _), do: "bg-boardblack" + + def highlight_square(true), do: "ring-inset ring-2 ring-yellow-400" + def highlight_square(false), do: "" +end diff --git a/lib/chesstrainer_web/live/endgame_live/form.ex b/lib/chesstrainer_web/live/endgame_live/form.ex new file mode 100644 index 0000000..e102fe6 --- /dev/null +++ b/lib/chesstrainer_web/live/endgame_live/form.ex @@ -0,0 +1,212 @@ +defmodule ChesstrainerWeb.EndgameLive.Form do + use ChesstrainerWeb, :live_view + + alias Chesstrainer.Repo + alias Chesstrainer.Tags + alias Chesstrainer.FEN + alias Chesstrainer.Endgames + alias Chesstrainer.Endgames.Endgame + + @impl true + def render(assigns) do + ~H""" + + <.header> + {@page_title} + <:subtitle>Use this form to manage endgame records in your database. + + + <.form for={@form} id="endgame-form" phx-change="validate" phx-submit="save"> + <.input field={@form[:fen]} type="text" label="Fen" disabled={@live_action == :edit} /> + <.input field={@form[:color]} type="text" label="Color" disabled /> + <.input field={@form[:key]} type="text" label="Key" /> + <.input field={@form[:rating]} type="number" label="Rating" /> + + +
+ + + + + + + + + +
+ <%= for tag <- @endgame.tags do %> + + {tag.name} + + + <% end %> +
+
+ + <.input field={@form[:message]} type="textarea" label="Message" /> + <.input field={@form[:notes]} type="textarea" label="Notes" /> + <.input + field={@form[:result]} + type="select" + options={[ + {"Win", :win}, + {"Draw", :draw}, + {"Loss", :loss} + ]} + label="Result" + /> + + +
+ """ + end + + @impl true + def mount(params, _session, socket) do + {:ok, + socket + |> assign(:return_to, return_to(params["return_to"])) + |> apply_action(socket.assigns.live_action, params)} + end + + defp return_to("show"), do: "show" + defp return_to(_), do: "index" + + defp apply_action(socket, :edit, %{"id" => id}) do + endgame = Endgames.get_endgame!(id) + + socket + |> assign(:page_title, "Edit Endgame") + |> assign(:endgame, endgame) + |> assign(:tag_query, "") + |> assign(:tag_results, []) + |> assign(:endgame, Repo.preload(endgame, :tags)) + |> assign(:form, to_form(Endgames.change_endgame(endgame))) + end + + defp apply_action(socket, :new, _params) do + endgame = %Endgame{} + + socket + |> assign(:page_title, "New Endgame") + |> assign(:endgame, endgame) + |> assign(:tag_query, "") + |> assign(:tag_results, []) + |> assign(:endgame, Repo.preload(endgame, :tags)) + |> assign(:form, to_form(Endgames.change_endgame(endgame))) + end + + @impl true + def handle_event("search_tags", %{"tag_search" => query}, socket) do + tags = Tags.search(query, "endgame") + + {:noreply, + socket + |> assign(:tag_query, query) + |> assign(:tag_results, tags)} + end + + def handle_event("add_tag", %{"id" => id}, socket) do + tag = Tags.get_tag!(id) + {:ok, endgame} = Endgames.add_tag(socket.assigns.endgame, tag) + + {:noreply, + socket + |> assign(:endgame, Repo.preload(endgame, :tags)) + |> assign(:tag_query, "") + |> assign(:tag_results, [])} + end + + def handle_event("create_tag", %{"name" => name}, socket) do + {:ok, tag} = Tags.get_or_create(name, "endgame") + {:ok, endgame} = Endgames.add_tag(socket.assigns.endgame, tag) + + {:noreply, + socket + |> assign(:endgame, Repo.preload(endgame, :tags)) + |> assign(:tag_query, "") + |> assign(:tag_results, [])} + end + + def handle_event("remove_tag", %{"id" => id}, socket) do + tag = Tags.get_tag!(id) + {:ok, endgame} = Endgames.remove_tag(socket.assigns.endgame, tag) + + {:noreply, + socket + |> assign(:endgame, Repo.preload(endgame, :tags))} + end + + def handle_event("validate", %{"endgame" => endgame_params}, socket) do + # Compute color from the incoming FEN + color = FEN.color_from_fen(endgame_params["fen"]) + + # Update the struct + endgame = %{socket.assigns.endgame | color: color} + + # Build the changeset *from the updated struct* + changeset = + endgame + |> Endgames.change_endgame() + |> Ecto.Changeset.put_change(:color, color) + + {:noreply, assign(socket, endgame: endgame, form: to_form(changeset, action: :validate))} + end + + def handle_event("save", %{"endgame" => endgame_params}, socket) do + save_endgame(socket, socket.assigns.live_action, endgame_params) + end + + defp save_endgame(socket, :edit, endgame_params) do + case Endgames.update_endgame(socket.assigns.endgame, endgame_params) do + {:ok, endgame} -> + {:noreply, + socket + |> put_flash(:info, "Endgame updated successfully") + |> push_navigate(to: return_path(socket.assigns.return_to, endgame))} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end + end + + defp save_endgame(socket, :new, endgame_params) do + color = FEN.color_from_fen(endgame_params["fen"]) + params = Map.put(endgame_params, "color", color) + + case Endgames.create_endgame(params) do + {:ok, endgame} -> + {:noreply, + socket + |> put_flash(:info, "Endgame created successfully") + |> push_navigate(to: return_path(socket.assigns.return_to, endgame))} + + {:error, %Ecto.Changeset{} = changeset} -> + {:noreply, assign(socket, form: to_form(changeset))} + end + end + + defp return_path("index", _endgame), do: ~p"/admin/endgames" + defp return_path("show", endgame), do: ~p"/admin/endgames/#{endgame}" +end diff --git a/lib/chesstrainer_web/live/endgame_live/index.ex b/lib/chesstrainer_web/live/endgame_live/index.ex new file mode 100644 index 0000000..91a5fc9 --- /dev/null +++ b/lib/chesstrainer_web/live/endgame_live/index.ex @@ -0,0 +1,71 @@ +defmodule ChesstrainerWeb.EndgameLive.Index do + use ChesstrainerWeb, :live_view + + alias Chesstrainer.Endgames + + @impl true + def render(assigns) do + ~H""" + + <.header> + Listing Endgames + <:actions> + <.button variant="primary" navigate={~p"/admin/endgames/new"}> + <.icon name="hero-plus" /> New Endgame + + + + + <.table + id="endgames" + rows={@streams.endgames} + row_click={fn {_id, endgame} -> JS.navigate(~p"/admin/endgames/#{endgame}") end} + > + <:col :let={{_id, endgame}} label="Fen">{endgame.fen} + <:col :let={{_id, endgame}} label="Color">{endgame.color} + <:col :let={{_id, endgame}} label="Key">{endgame.key} + <:col :let={{_id, endgame}} label="Rating">{endgame.rating} + <:col :let={{_id, endgame}} label="Message">{endgame.message} + <:col :let={{_id, endgame}} label="Notes">{endgame.notes} + <:col :let={{_id, endgame}} label="Result">{endgame.result} + <:col :let={{_id, endgame}} label="Attempted">{endgame.times_attempted} + <:col :let={{_id, endgame}} label="Solved">{endgame.times_solved} + <:action :let={{_id, endgame}}> +
+ <.link navigate={~p"/admin/endgames/#{endgame}"}>Show +
+ <.link navigate={~p"/admin/endgames/#{endgame}/edit"}>Edit + + <:action :let={{id, endgame}}> + <.link + phx-click={JS.push("delete", value: %{id: endgame.id}) |> hide("##{id}")} + data-confirm="Are you sure?" + > + Delete + + + +
+ """ + end + + @impl true + def mount(_params, _session, socket) do + {:ok, + socket + |> assign(:page_title, "Listing Endgames") + |> stream(:endgames, list_endgames())} + end + + @impl true + def handle_event("delete", %{"id" => id}, socket) do + endgame = Endgames.get_endgame!(id) + {:ok, _} = Endgames.delete_endgame(endgame) + + {:noreply, stream_delete(socket, :endgames, endgame)} + end + + defp list_endgames() do + 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 new file mode 100644 index 0000000..c5d6811 --- /dev/null +++ b/lib/chesstrainer_web/live/endgame_live/show.ex @@ -0,0 +1,64 @@ +defmodule ChesstrainerWeb.EndgameLive.Show do + use ChesstrainerWeb, :live_view + + alias Chesstrainer.Endgames + alias Chesstrainer.Game + + import ChesstrainerWeb.Board.BoardComponent + + @impl true + def render(assigns) do + game = + case Game.game_from_fen(assigns.endgame.fen, :endgame) do + {:ok, g} -> g + {:error, :invalid_fen, g} -> g + end + + # TODO once we return {:error, :invalid_fen, game}, then put flash to show it is invalid + # write tests for this + + assigns = + assigns + |> assign(:game, game) + |> assign(:game_type, :endgame) + + ~H""" + + <.header> + Endgame {@endgame.id} + <:subtitle>This is a endgame record from your database. + <:actions> + <.button navigate={~p"/admin/endgames"}> + <.icon name="hero-arrow-left" /> + + <.button variant="primary" navigate={~p"/admin/endgames/#{@endgame}/edit?return_to=show"}> + <.icon name="hero-pencil-square" /> Edit endgame + + + + + <.board game={@game} myself="board" /> + + <.list> + <:item title="Fen">{@endgame.fen} + <:item title="Key">{@endgame.key} + <:item title="Color">{@endgame.color} + <:item title="Rating">{@endgame.rating} + <:item title="Message">{@endgame.message} + <:item title="Notes">{@endgame.notes} + <:item title="Result">{@endgame.result} + <:item title="Attempted">{@endgame.times_attempted} + <:item title="Solved">{@endgame.times_solved} + + + """ + end + + @impl true + def mount(%{"id" => id}, _session, socket) do + {:ok, + socket + |> assign(:page_title, "Show Endgame") + |> assign(:endgame, Endgames.get_endgame!(id))} + end +end diff --git a/lib/chesstrainer_web/router.ex b/lib/chesstrainer_web/router.ex index 1052c59..59fb058 100644 --- a/lib/chesstrainer_web/router.ex +++ b/lib/chesstrainer_web/router.ex @@ -22,6 +22,17 @@ defmodule ChesstrainerWeb.Router do live "/chess-test", ChessTestLive end + scope "/admin", ChesstrainerWeb do + pipe_through :browser + + live_session :endgames do + live "/endgames", EndgameLive.Index, :index + live "/endgames/new", EndgameLive.Form, :new + live "/endgames/:id", EndgameLive.Show, :show + live "/endgames/:id/edit", EndgameLive.Form, :edit + end + end + # Other scopes may use custom stacks. # scope "/api", ChesstrainerWeb do # pipe_through :api diff --git a/mix.exs b/mix.exs index b8cfcd7..90f407d 100644 --- a/mix.exs +++ b/mix.exs @@ -65,7 +65,8 @@ defmodule Chesstrainer.MixProject do {:gettext, "~> 1.0"}, {:jason, "~> 1.2"}, {:dns_cluster, "~> 0.2.0"}, - {:bandit, "~> 1.5"} + {:bandit, "~> 1.5"}, + {:chex, git: "https://github.com/therealowenrees/chex.git", tag: "v0.2.0-alpha.2"} ] end diff --git a/mix.lock b/mix.lock index aba7a3c..34a27c9 100644 --- a/mix.lock +++ b/mix.lock @@ -1,6 +1,7 @@ %{ "bandit": {:hex, :bandit, "1.12.0", "6c5214daa2469644ac4ab0113b98abc24f75e348378e6a974c6343b3e5da22ef", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.5", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "45dac82dc86f45cf4a196dee9cc5a8b791d9c9469d996055f055e6ee36c66e20"}, "cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"}, + "chex": {:git, "https://github.com/therealowenrees/chex.git", "655b5c3df6c5297689d7872bbce1e86c6c629d26", [tag: "v0.2.0-alpha.2"]}, "db_connection": {:hex, :db_connection, "2.10.1", "d5465f6bcc125c1b8981c1dbf23c193ca16f446ec0b25832dc174f74f18be510", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "18ed94c6e627b4bf452dbd4df61b69a35a1e768525140bc1917b7a685026a6a3"}, "decimal": {:hex, :decimal, "3.1.1", "430d87b04011ce6cbd4fd205be758311a81f87d552d40904abd00f015935b1d0", [:mix], [], "hexpm", "c5f25f2ced74a0587d03e6023f595db8e924c9d3922c8c8ffd9edfc4498cf1f6"}, "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, @@ -21,6 +22,7 @@ "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mint": {:hex, :mint, "1.9.0", "d6f534c2a3e98b2a8cc749b4796eb77e9e3af79a76f96e4c74035a827de0d318", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "007154c7d8c43916aed3c93afd1f11aebbaa9c5ff4b7ba55ebe0d17ee0296042"}, "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"}, "phoenix": {:hex, :phoenix, "1.8.8", "ada3d761359274178180c0e992ef0c2b536bd7c3bd75ebba94acbf39ab4347fe", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "f0c843037bd2e7012fc1d1ec9574dfa6972b7e3d09e9b77fd23aa283af0aa994"}, "phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"}, diff --git a/priv/repo/migrations/20251105204020_create_tags.exs b/priv/repo/migrations/20251105204020_create_tags.exs new file mode 100644 index 0000000..9644634 --- /dev/null +++ b/priv/repo/migrations/20251105204020_create_tags.exs @@ -0,0 +1,14 @@ +defmodule Chesstrainer.Repo.Migrations.CreateTags do + use Ecto.Migration + + def change do + create table(:tags) do + add :name, :string + add :category, :string + + timestamps(type: :utc_datetime) + end + + create unique_index(:tags, [:name, :category]) + end +end diff --git a/priv/repo/migrations/20251127164545_create_endgames.exs b/priv/repo/migrations/20251127164545_create_endgames.exs new file mode 100644 index 0000000..b100576 --- /dev/null +++ b/priv/repo/migrations/20251127164545_create_endgames.exs @@ -0,0 +1,23 @@ +defmodule Chesstrainer.Repo.Migrations.CreateEndgames do + use Ecto.Migration + + def change do + create table(:endgames, primary_key: false) do + add :id, :binary_id, primary_key: true + add :fen, :string + add :color, :string + add :key, :string + add :message, :text + add :notes, :text + add :result, :string + add :rating, :integer, default: 1500 + add :rating_deviation, :integer, default: 350 + add :times_attempted, :integer, default: 0 + add :times_solved, :integer, default: 0 + + timestamps(type: :utc_datetime) + end + + create unique_index(:endgames, [:fen]) + end +end diff --git a/priv/repo/migrations/20251205210539_create_endgames_tags.exs b/priv/repo/migrations/20251205210539_create_endgames_tags.exs new file mode 100644 index 0000000..d8b2aa1 --- /dev/null +++ b/priv/repo/migrations/20251205210539_create_endgames_tags.exs @@ -0,0 +1,12 @@ +defmodule Chesstrainer.Repo.Migrations.CreateEndgamesTags do + use Ecto.Migration + + def change do + create table(:endgames_tags, primary_key: false) do + add :endgame_id, references(:endgames, type: :binary_id, on_delete: :delete_all) + add :tag_id, references(:tags, on_delete: :delete_all) + end + + create unique_index(:endgames_tags, [:endgame_id, :tag_id]) + end +end diff --git a/priv/repo/seeds.exs b/priv/repo/seeds.exs index 5b9b28b..179b8af 100644 --- a/priv/repo/seeds.exs +++ b/priv/repo/seeds.exs @@ -9,3 +9,57 @@ # # We recommend using the bang functions (`insert!`, `update!` # and so on) as they will fail if something goes wrong. + +alias Chesstrainer.Repo +alias Chesstrainer.Endgames.Endgame +alias Chesstrainer.Tags.Tag + +now = DateTime.utc_now() |> DateTime.truncate(:second) + +IO.puts("🌱 Seeding basic endgames into DB...") + +Repo.insert_all(Endgame, [ + %{ + id: Ecto.UUID.generate(), + fen: "8/8/3k4/8/8/3K1R2/8/8 w - - 0 1", + color: :white, + key: "KR v K", + message: "Rook vs king endgame", + notes: "Basic rook endgame", + result: :win, + inserted_at: now, + updated_at: now + }, + %{ + id: Ecto.UUID.generate(), + fen: "6k1/5p2/6p1/8/7p/8/6PP/6K1 b - - 0 0", + color: :black, + key: "KPPP v KPP", + message: "3 pawns vs 2 pawns", + notes: "https://www.chessgames.com/perl/chessgame?gid=1017147", + result: :win, + inserted_at: now, + updated_at: now + } +]) + +IO.puts("✅ Done seeding endgames.") + +IO.puts("🌱 Seeding endgame tags into DB...") + +Repo.insert_all(Tag, [ + %{ + name: "mate in 2", + category: :endgame, + inserted_at: now, + updated_at: now + }, + %{ + name: "mate in 3", + category: :endgame, + inserted_at: now, + updated_at: now + } +]) + +IO.puts("✅ Done seeding endgame tags.") diff --git a/priv/static/backups/tablebase.det b/priv/static/backups/tablebase.det new file mode 100644 index 0000000..c1ae47e Binary files /dev/null and b/priv/static/backups/tablebase.det differ diff --git a/priv/static/images/pieces/black/bishop-cb69fc1a80edd0241466c0d47b116db2.svg b/priv/static/images/pieces/black/bishop-cb69fc1a80edd0241466c0d47b116db2.svg new file mode 100644 index 0000000..b6a8006 --- /dev/null +++ b/priv/static/images/pieces/black/bishop-cb69fc1a80edd0241466c0d47b116db2.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/priv/static/images/pieces/black/bishop-cb69fc1a80edd0241466c0d47b116db2.svg.gz b/priv/static/images/pieces/black/bishop-cb69fc1a80edd0241466c0d47b116db2.svg.gz new file mode 100644 index 0000000..473a420 Binary files /dev/null and b/priv/static/images/pieces/black/bishop-cb69fc1a80edd0241466c0d47b116db2.svg.gz differ diff --git a/priv/static/images/pieces/black/bishop.svg b/priv/static/images/pieces/black/bishop.svg new file mode 100644 index 0000000..b6a8006 --- /dev/null +++ b/priv/static/images/pieces/black/bishop.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/priv/static/images/pieces/black/bishop.svg.gz b/priv/static/images/pieces/black/bishop.svg.gz new file mode 100644 index 0000000..473a420 Binary files /dev/null and b/priv/static/images/pieces/black/bishop.svg.gz differ diff --git a/priv/static/images/pieces/black/king-e3451fff6ecf0fcd6de9217ccec6ba84.svg b/priv/static/images/pieces/black/king-e3451fff6ecf0fcd6de9217ccec6ba84.svg new file mode 100644 index 0000000..ba2ac9f --- /dev/null +++ b/priv/static/images/pieces/black/king-e3451fff6ecf0fcd6de9217ccec6ba84.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/priv/static/images/pieces/black/king-e3451fff6ecf0fcd6de9217ccec6ba84.svg.gz b/priv/static/images/pieces/black/king-e3451fff6ecf0fcd6de9217ccec6ba84.svg.gz new file mode 100644 index 0000000..3e5e17a Binary files /dev/null and b/priv/static/images/pieces/black/king-e3451fff6ecf0fcd6de9217ccec6ba84.svg.gz differ diff --git a/priv/static/images/pieces/black/king.svg b/priv/static/images/pieces/black/king.svg new file mode 100644 index 0000000..ba2ac9f --- /dev/null +++ b/priv/static/images/pieces/black/king.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/priv/static/images/pieces/black/king.svg.gz b/priv/static/images/pieces/black/king.svg.gz new file mode 100644 index 0000000..3e5e17a Binary files /dev/null and b/priv/static/images/pieces/black/king.svg.gz differ diff --git a/priv/static/images/pieces/black/knight-7b95d1a433d0a64a1e34ed69ca8ad127.svg b/priv/static/images/pieces/black/knight-7b95d1a433d0a64a1e34ed69ca8ad127.svg new file mode 100644 index 0000000..203c6fe --- /dev/null +++ b/priv/static/images/pieces/black/knight-7b95d1a433d0a64a1e34ed69ca8ad127.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + diff --git a/priv/static/images/pieces/black/knight-7b95d1a433d0a64a1e34ed69ca8ad127.svg.gz b/priv/static/images/pieces/black/knight-7b95d1a433d0a64a1e34ed69ca8ad127.svg.gz new file mode 100644 index 0000000..1e43ee9 Binary files /dev/null and b/priv/static/images/pieces/black/knight-7b95d1a433d0a64a1e34ed69ca8ad127.svg.gz differ diff --git a/priv/static/images/pieces/black/knight.svg b/priv/static/images/pieces/black/knight.svg new file mode 100644 index 0000000..203c6fe --- /dev/null +++ b/priv/static/images/pieces/black/knight.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + diff --git a/priv/static/images/pieces/black/knight.svg.gz b/priv/static/images/pieces/black/knight.svg.gz new file mode 100644 index 0000000..1e43ee9 Binary files /dev/null and b/priv/static/images/pieces/black/knight.svg.gz differ diff --git a/priv/static/images/pieces/black/pawn-876f1ece4514c9b19b748e5b985e74f2.svg b/priv/static/images/pieces/black/pawn-876f1ece4514c9b19b748e5b985e74f2.svg new file mode 100644 index 0000000..b534de8 --- /dev/null +++ b/priv/static/images/pieces/black/pawn-876f1ece4514c9b19b748e5b985e74f2.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/priv/static/images/pieces/black/pawn-876f1ece4514c9b19b748e5b985e74f2.svg.gz b/priv/static/images/pieces/black/pawn-876f1ece4514c9b19b748e5b985e74f2.svg.gz new file mode 100644 index 0000000..2eb7680 Binary files /dev/null and b/priv/static/images/pieces/black/pawn-876f1ece4514c9b19b748e5b985e74f2.svg.gz differ diff --git a/priv/static/images/pieces/black/pawn.svg b/priv/static/images/pieces/black/pawn.svg new file mode 100644 index 0000000..b534de8 --- /dev/null +++ b/priv/static/images/pieces/black/pawn.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/priv/static/images/pieces/black/pawn.svg.gz b/priv/static/images/pieces/black/pawn.svg.gz new file mode 100644 index 0000000..2eb7680 Binary files /dev/null and b/priv/static/images/pieces/black/pawn.svg.gz differ diff --git a/priv/static/images/pieces/black/queen-8e8aadb6f4a4229d3cb0650301f5a9fd.svg b/priv/static/images/pieces/black/queen-8e8aadb6f4a4229d3cb0650301f5a9fd.svg new file mode 100644 index 0000000..e557734 --- /dev/null +++ b/priv/static/images/pieces/black/queen-8e8aadb6f4a4229d3cb0650301f5a9fd.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/priv/static/images/pieces/black/queen-8e8aadb6f4a4229d3cb0650301f5a9fd.svg.gz b/priv/static/images/pieces/black/queen-8e8aadb6f4a4229d3cb0650301f5a9fd.svg.gz new file mode 100644 index 0000000..3cdc4a6 Binary files /dev/null and b/priv/static/images/pieces/black/queen-8e8aadb6f4a4229d3cb0650301f5a9fd.svg.gz differ diff --git a/priv/static/images/pieces/black/queen.svg b/priv/static/images/pieces/black/queen.svg new file mode 100644 index 0000000..e557734 --- /dev/null +++ b/priv/static/images/pieces/black/queen.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/priv/static/images/pieces/black/queen.svg.gz b/priv/static/images/pieces/black/queen.svg.gz new file mode 100644 index 0000000..3cdc4a6 Binary files /dev/null and b/priv/static/images/pieces/black/queen.svg.gz differ diff --git a/priv/static/images/pieces/black/rook-9a79237a3c4acee7893525f1bfc456ca.svg b/priv/static/images/pieces/black/rook-9a79237a3c4acee7893525f1bfc456ca.svg new file mode 100644 index 0000000..4eec43c --- /dev/null +++ b/priv/static/images/pieces/black/rook-9a79237a3c4acee7893525f1bfc456ca.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + diff --git a/priv/static/images/pieces/black/rook-9a79237a3c4acee7893525f1bfc456ca.svg.gz b/priv/static/images/pieces/black/rook-9a79237a3c4acee7893525f1bfc456ca.svg.gz new file mode 100644 index 0000000..2196985 Binary files /dev/null and b/priv/static/images/pieces/black/rook-9a79237a3c4acee7893525f1bfc456ca.svg.gz differ diff --git a/priv/static/images/pieces/black/rook.svg b/priv/static/images/pieces/black/rook.svg new file mode 100644 index 0000000..4eec43c --- /dev/null +++ b/priv/static/images/pieces/black/rook.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + diff --git a/priv/static/images/pieces/black/rook.svg.gz b/priv/static/images/pieces/black/rook.svg.gz new file mode 100644 index 0000000..2196985 Binary files /dev/null and b/priv/static/images/pieces/black/rook.svg.gz differ diff --git a/priv/static/images/pieces/white/bishop-b99f3830dcbf60642c26befe1514ebbc.svg b/priv/static/images/pieces/white/bishop-b99f3830dcbf60642c26befe1514ebbc.svg new file mode 100644 index 0000000..18f034d --- /dev/null +++ b/priv/static/images/pieces/white/bishop-b99f3830dcbf60642c26befe1514ebbc.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/priv/static/images/pieces/white/bishop-b99f3830dcbf60642c26befe1514ebbc.svg.gz b/priv/static/images/pieces/white/bishop-b99f3830dcbf60642c26befe1514ebbc.svg.gz new file mode 100644 index 0000000..0800e6e Binary files /dev/null and b/priv/static/images/pieces/white/bishop-b99f3830dcbf60642c26befe1514ebbc.svg.gz differ diff --git a/priv/static/images/pieces/white/bishop.svg b/priv/static/images/pieces/white/bishop.svg new file mode 100644 index 0000000..18f034d --- /dev/null +++ b/priv/static/images/pieces/white/bishop.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/priv/static/images/pieces/white/bishop.svg.gz b/priv/static/images/pieces/white/bishop.svg.gz new file mode 100644 index 0000000..0800e6e Binary files /dev/null and b/priv/static/images/pieces/white/bishop.svg.gz differ diff --git a/priv/static/images/pieces/white/king-27fd3307056d10dae685faf4ca8f4488.svg b/priv/static/images/pieces/white/king-27fd3307056d10dae685faf4ca8f4488.svg new file mode 100644 index 0000000..632ca1a --- /dev/null +++ b/priv/static/images/pieces/white/king-27fd3307056d10dae685faf4ca8f4488.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/priv/static/images/pieces/white/king-27fd3307056d10dae685faf4ca8f4488.svg.gz b/priv/static/images/pieces/white/king-27fd3307056d10dae685faf4ca8f4488.svg.gz new file mode 100644 index 0000000..5cce64a Binary files /dev/null and b/priv/static/images/pieces/white/king-27fd3307056d10dae685faf4ca8f4488.svg.gz differ diff --git a/priv/static/images/pieces/white/king.svg b/priv/static/images/pieces/white/king.svg new file mode 100644 index 0000000..632ca1a --- /dev/null +++ b/priv/static/images/pieces/white/king.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/priv/static/images/pieces/white/king.svg.gz b/priv/static/images/pieces/white/king.svg.gz new file mode 100644 index 0000000..5cce64a Binary files /dev/null and b/priv/static/images/pieces/white/king.svg.gz differ diff --git a/priv/static/images/pieces/white/knight-b243da7dc2f0791b4b6915ed2091b288.svg b/priv/static/images/pieces/white/knight-b243da7dc2f0791b4b6915ed2091b288.svg new file mode 100644 index 0000000..92feeee --- /dev/null +++ b/priv/static/images/pieces/white/knight-b243da7dc2f0791b4b6915ed2091b288.svg @@ -0,0 +1,19 @@ + + + + + + + + + + diff --git a/priv/static/images/pieces/white/knight-b243da7dc2f0791b4b6915ed2091b288.svg.gz b/priv/static/images/pieces/white/knight-b243da7dc2f0791b4b6915ed2091b288.svg.gz new file mode 100644 index 0000000..dd8df6e Binary files /dev/null and b/priv/static/images/pieces/white/knight-b243da7dc2f0791b4b6915ed2091b288.svg.gz differ diff --git a/priv/static/images/pieces/white/knight.svg b/priv/static/images/pieces/white/knight.svg new file mode 100644 index 0000000..92feeee --- /dev/null +++ b/priv/static/images/pieces/white/knight.svg @@ -0,0 +1,19 @@ + + + + + + + + + + diff --git a/priv/static/images/pieces/white/knight.svg.gz b/priv/static/images/pieces/white/knight.svg.gz new file mode 100644 index 0000000..dd8df6e Binary files /dev/null and b/priv/static/images/pieces/white/knight.svg.gz differ diff --git a/priv/static/images/pieces/white/pawn-e38a4f66149924b57e4617dbbfe10ef7.svg b/priv/static/images/pieces/white/pawn-e38a4f66149924b57e4617dbbfe10ef7.svg new file mode 100644 index 0000000..b265fe1 --- /dev/null +++ b/priv/static/images/pieces/white/pawn-e38a4f66149924b57e4617dbbfe10ef7.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/priv/static/images/pieces/white/pawn-e38a4f66149924b57e4617dbbfe10ef7.svg.gz b/priv/static/images/pieces/white/pawn-e38a4f66149924b57e4617dbbfe10ef7.svg.gz new file mode 100644 index 0000000..ede930b Binary files /dev/null and b/priv/static/images/pieces/white/pawn-e38a4f66149924b57e4617dbbfe10ef7.svg.gz differ diff --git a/priv/static/images/pieces/white/pawn.svg b/priv/static/images/pieces/white/pawn.svg new file mode 100644 index 0000000..b265fe1 --- /dev/null +++ b/priv/static/images/pieces/white/pawn.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/priv/static/images/pieces/white/pawn.svg.gz b/priv/static/images/pieces/white/pawn.svg.gz new file mode 100644 index 0000000..ede930b Binary files /dev/null and b/priv/static/images/pieces/white/pawn.svg.gz differ diff --git a/priv/static/images/pieces/white/queen-518cd5f8585dbdb35199fa1edf31979f.svg b/priv/static/images/pieces/white/queen-518cd5f8585dbdb35199fa1edf31979f.svg new file mode 100644 index 0000000..8df7c8f --- /dev/null +++ b/priv/static/images/pieces/white/queen-518cd5f8585dbdb35199fa1edf31979f.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/priv/static/images/pieces/white/queen-518cd5f8585dbdb35199fa1edf31979f.svg.gz b/priv/static/images/pieces/white/queen-518cd5f8585dbdb35199fa1edf31979f.svg.gz new file mode 100644 index 0000000..e519860 Binary files /dev/null and b/priv/static/images/pieces/white/queen-518cd5f8585dbdb35199fa1edf31979f.svg.gz differ diff --git a/priv/static/images/pieces/white/queen.svg b/priv/static/images/pieces/white/queen.svg new file mode 100644 index 0000000..8df7c8f --- /dev/null +++ b/priv/static/images/pieces/white/queen.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/priv/static/images/pieces/white/queen.svg.gz b/priv/static/images/pieces/white/queen.svg.gz new file mode 100644 index 0000000..e519860 Binary files /dev/null and b/priv/static/images/pieces/white/queen.svg.gz differ diff --git a/priv/static/images/pieces/white/rook-0095917f84de3487e19f9d8d46876859.svg b/priv/static/images/pieces/white/rook-0095917f84de3487e19f9d8d46876859.svg new file mode 100644 index 0000000..ac03578 --- /dev/null +++ b/priv/static/images/pieces/white/rook-0095917f84de3487e19f9d8d46876859.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + diff --git a/priv/static/images/pieces/white/rook-0095917f84de3487e19f9d8d46876859.svg.gz b/priv/static/images/pieces/white/rook-0095917f84de3487e19f9d8d46876859.svg.gz new file mode 100644 index 0000000..30b59b7 Binary files /dev/null and b/priv/static/images/pieces/white/rook-0095917f84de3487e19f9d8d46876859.svg.gz differ diff --git a/priv/static/images/pieces/white/rook.svg b/priv/static/images/pieces/white/rook.svg new file mode 100644 index 0000000..ac03578 --- /dev/null +++ b/priv/static/images/pieces/white/rook.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + diff --git a/priv/static/images/pieces/white/rook.svg.gz b/priv/static/images/pieces/white/rook.svg.gz new file mode 100644 index 0000000..30b59b7 Binary files /dev/null and b/priv/static/images/pieces/white/rook.svg.gz differ diff --git a/test/chesstrainer/tags_test.exs b/test/chesstrainer/tags_test.exs new file mode 100644 index 0000000..7f06c2f --- /dev/null +++ b/test/chesstrainer/tags_test.exs @@ -0,0 +1,61 @@ +defmodule Chesstrainer.TagsTest do + use Chesstrainer.DataCase + + alias Chesstrainer.Tags + + describe "tags" do + alias Chesstrainer.Tags.Tag + + import Chesstrainer.TagsFixtures + + @invalid_attrs %{name: nil, category: nil} + + test "list_tags/0 returns all tags" do + tag = tag_fixture() + assert Tags.list_tags() == [tag] + end + + test "get_tag!/1 returns the tag with given id" do + tag = tag_fixture() + assert Tags.get_tag!(tag.id) == tag + end + + test "create_tag/1 with valid data creates a tag" do + valid_attrs = %{name: "some name", category: "some category"} + + assert {:ok, %Tag{} = tag} = Tags.create_tag(valid_attrs) + assert tag.name == "some name" + assert tag.category == "some category" + end + + test "create_tag/1 with invalid data returns error changeset" do + assert {:error, %Ecto.Changeset{}} = Tags.create_tag(@invalid_attrs) + end + + test "update_tag/2 with valid data updates the tag" do + tag = tag_fixture() + update_attrs = %{name: "some updated name", category: "some updated category"} + + assert {:ok, %Tag{} = tag} = Tags.update_tag(tag, update_attrs) + assert tag.name == "some updated name" + assert tag.category == "some updated category" + end + + test "update_tag/2 with invalid data returns error changeset" do + tag = tag_fixture() + assert {:error, %Ecto.Changeset{}} = Tags.update_tag(tag, @invalid_attrs) + assert tag == Tags.get_tag!(tag.id) + end + + test "delete_tag/1 deletes the tag" do + tag = tag_fixture() + assert {:ok, %Tag{}} = Tags.delete_tag(tag) + assert_raise Ecto.NoResultsError, fn -> Tags.get_tag!(tag.id) end + end + + test "change_tag/1 returns a tag changeset" do + tag = tag_fixture() + assert %Ecto.Changeset{} = Tags.change_tag(tag) + end + end +end diff --git a/test/support/fixtures/tags_fixtures.ex b/test/support/fixtures/tags_fixtures.ex new file mode 100644 index 0000000..4fea40f --- /dev/null +++ b/test/support/fixtures/tags_fixtures.ex @@ -0,0 +1,26 @@ +defmodule Chesstrainer.TagsFixtures do + @moduledoc """ + This module defines test helpers for creating + entities via the `Chesstrainer.Tags` context. + """ + + @doc """ + Generate a unique tag name. + """ + def unique_tag_name, do: "some name#{System.unique_integer([:positive])}" + + @doc """ + Generate a tag. + """ + def tag_fixture(attrs \\ %{}) do + {:ok, tag} = + attrs + |> Enum.into(%{ + category: "some category", + name: unique_tag_name() + }) + |> Chesstrainer.Tags.create_tag() + + tag + end +end