From ec581b24e7b675d699af96a71f0babe1b4e44736 Mon Sep 17 00:00:00 2001 From: Owen Date: Tue, 11 Aug 2026 22:04:47 +0200 Subject: [PATCH] normalise FENs - trim and fuill move to 1 on addition --- .gitignore | 3 ++ lib/chesstrainer/endgames/endgame.ex | 10 ++++- lib/chesstrainer/fen.ex | 33 +++++++++++++++ test/chesstrainer/endgames_test.exs | 20 +++++++-- test/chesstrainer/fen_test.exs | 62 ++++++++++++++++++++++++++++ 5 files changed, 123 insertions(+), 5 deletions(-) create mode 100644 test/chesstrainer/fen_test.exs 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/lib/chesstrainer/endgames/endgame.ex b/lib/chesstrainer/endgames/endgame.ex index cc87a34..3055c9e 100644 --- a/lib/chesstrainer/endgames/endgame.ex +++ b/lib/chesstrainer/endgames/endgame.ex @@ -30,7 +30,7 @@ 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*$/, @@ -45,6 +45,14 @@ defmodule Chesstrainer.Endgames.Endgame do |> 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). 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/test/chesstrainer/endgames_test.exs b/test/chesstrainer/endgames_test.exs index 9e4d9dd..bf7716d 100644 --- a/test/chesstrainer/endgames_test.exs +++ b/test/chesstrainer/endgames_test.exs @@ -17,7 +17,7 @@ defmodule Chesstrainer.EndgamesTest do assert {:ok, %Endgame{}} = Endgames.create_endgame(attrs) end - test "rejects a FEN with fullmove number zero" do + test "normalizes fullmove 0 to 1 before inserting" do attrs = %{ fen: "6k1/5p2/6p1/8/7p/8/6PP/6K1 b - - 0 0", color: :black, @@ -26,9 +26,21 @@ defmodule Chesstrainer.EndgamesTest do rating: 1500 } - assert {:error, changeset} = Endgames.create_endgame(attrs) - assert [msg] = errors_on(changeset).fen - assert msg =~ "fullmove number" + 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 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