feen 5.0.0.beta7 → 5.0.0.beta9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +381 -245
- data/lib/feen/dumper/games_turn.rb +32 -29
- data/lib/feen/dumper/piece_placement.rb +85 -63
- data/lib/feen/dumper/pieces_in_hand.rb +32 -54
- data/lib/feen/dumper.rb +2 -2
- data/lib/feen/parser/games_turn.rb +40 -20
- data/lib/feen/parser/piece_placement.rb +232 -558
- data/lib/feen/parser/pieces_in_hand.rb +93 -103
- data/lib/feen/parser.rb +3 -3
- data/lib/feen.rb +20 -9
- metadata +3 -6
- data/lib/feen/dumper/pieces_in_hand/errors.rb +0 -12
- data/lib/feen/parser/games_turn/errors.rb +0 -14
- data/lib/feen/parser/games_turn/valid_games_turn_pattern.rb +0 -24
- data/lib/feen/parser/pieces_in_hand/errors.rb +0 -20
- data/lib/feen/parser/pieces_in_hand/pnn_patterns.rb +0 -89
@@ -4,12 +4,13 @@ module Feen
|
|
4
4
|
module Dumper
|
5
5
|
# Handles conversion of games turn data to FEEN notation string
|
6
6
|
module GamesTurn
|
7
|
+
# Error messages for validation
|
7
8
|
ERRORS = {
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
9
|
+
invalid_type: "%s must be a String, got %s",
|
10
|
+
empty_string: "%s cannot be empty",
|
11
|
+
invalid_chars: "%s must contain only alphabetic characters (a-z, A-Z)",
|
12
|
+
mixed_case: "%s has mixed case: %s",
|
13
|
+
same_casing: "One variant must be uppercase and the other lowercase"
|
13
14
|
}.freeze
|
14
15
|
|
15
16
|
# Converts the active and inactive variant identifiers to a FEEN-formatted games turn string
|
@@ -17,6 +18,15 @@ module Feen
|
|
17
18
|
# @param active_variant [String] Identifier for the player to move and their game variant
|
18
19
|
# @param inactive_variant [String] Identifier for the opponent and their game variant
|
19
20
|
# @return [String] FEEN-formatted games turn string
|
21
|
+
# @raise [ArgumentError] If the variant identifiers are invalid
|
22
|
+
#
|
23
|
+
# @example Valid games turn
|
24
|
+
# GamesTurn.dump("CHESS", "chess")
|
25
|
+
# # => "CHESS/chess"
|
26
|
+
#
|
27
|
+
# @example Invalid - same casing
|
28
|
+
# GamesTurn.dump("CHESS", "MAKRUK")
|
29
|
+
# # => ArgumentError: One variant must be uppercase and the other lowercase
|
20
30
|
def self.dump(active_variant, inactive_variant)
|
21
31
|
validate_variants(active_variant, inactive_variant)
|
22
32
|
"#{active_variant}/#{inactive_variant}"
|
@@ -29,38 +39,31 @@ module Feen
|
|
29
39
|
# @raise [ArgumentError] If the variant identifiers are invalid
|
30
40
|
# @return [void]
|
31
41
|
private_class_method def self.validate_variants(active, inactive)
|
32
|
-
# Validate basic type and
|
42
|
+
# Validate basic type, presence and format
|
33
43
|
[["Active variant", active], ["Inactive variant", inactive]].each do |name, variant|
|
34
|
-
|
35
|
-
raise ArgumentError, format(ERRORS[:
|
36
|
-
raise ArgumentError, ERRORS[:chars] unless variant.match?(/\A[a-zA-Z]+\z/)
|
37
|
-
end
|
44
|
+
# Type validation
|
45
|
+
raise ArgumentError, format(ERRORS[:invalid_type], name, variant.class) unless variant.is_a?(String)
|
38
46
|
|
39
|
-
|
40
|
-
|
41
|
-
inactive_uppercase = inactive == inactive.upcase && inactive != inactive.downcase
|
47
|
+
# Empty validation
|
48
|
+
raise ArgumentError, format(ERRORS[:empty_string], name) if variant.empty?
|
42
49
|
|
43
|
-
|
44
|
-
|
50
|
+
# Character validation
|
51
|
+
raise ArgumentError, format(ERRORS[:invalid_chars], name) unless variant.match?(/\A[a-zA-Z]+\z/)
|
45
52
|
|
46
|
-
|
47
|
-
|
48
|
-
|
53
|
+
# Mixed case validation
|
54
|
+
unless variant == variant.upcase || variant == variant.downcase
|
55
|
+
raise ArgumentError, format(ERRORS[:mixed_case], name, variant)
|
56
|
+
end
|
49
57
|
end
|
50
58
|
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
if !active_uppercase && active != active.downcase
|
56
|
-
raise ArgumentError, format(ERRORS[:mixed], "Active variant", active)
|
57
|
-
end
|
59
|
+
# Casing difference validation
|
60
|
+
active_is_uppercase = active == active.upcase
|
61
|
+
inactive_is_uppercase = inactive == inactive.upcase
|
58
62
|
|
59
|
-
|
60
|
-
|
61
|
-
end
|
63
|
+
# Both variants must have different casing
|
64
|
+
return unless active_is_uppercase == inactive_is_uppercase
|
62
65
|
|
63
|
-
|
66
|
+
raise ArgumentError, ERRORS[:same_casing]
|
64
67
|
end
|
65
68
|
end
|
66
69
|
end
|
@@ -2,69 +2,91 @@
|
|
2
2
|
|
3
3
|
module Feen
|
4
4
|
module Dumper
|
5
|
+
# Handles conversion of piece placement data to FEEN notation string
|
5
6
|
module PiecePlacement
|
7
|
+
# Error messages
|
8
|
+
ERRORS = {
|
9
|
+
invalid_type: "Piece placement must be an Array, got %s",
|
10
|
+
inconsistent_shape: "Inconsistent dimension structure detected",
|
11
|
+
invalid_cell: "Invalid cell content: %s (must be String)"
|
12
|
+
}.freeze
|
13
|
+
|
6
14
|
# Converts a piece placement structure to a FEEN-compliant string
|
7
15
|
#
|
8
16
|
# @param piece_placement [Array] Hierarchical array representing the board where:
|
9
17
|
# - Empty squares are represented by empty strings ("")
|
10
|
-
# - Pieces are represented by strings (e.g., "r", "R
|
18
|
+
# - Pieces are represented by strings (e.g., "r", "R", "+P", "K'")
|
11
19
|
# - Dimensions are represented by nested arrays
|
12
20
|
# @return [String] FEEN piece placement string
|
13
21
|
# @raise [ArgumentError] If the piece placement structure is invalid
|
22
|
+
#
|
23
|
+
# @example 2D chess board
|
24
|
+
# PiecePlacement.dump([
|
25
|
+
# ["r", "n", "b", "q", "k", "b", "n", "r"],
|
26
|
+
# ["p", "p", "p", "p", "p", "p", "p", "p"],
|
27
|
+
# ["", "", "", "", "", "", "", ""],
|
28
|
+
# ["", "", "", "", "", "", "", ""],
|
29
|
+
# ["", "", "", "", "", "", "", ""],
|
30
|
+
# ["", "", "", "", "", "", "", ""],
|
31
|
+
# ["P", "P", "P", "P", "P", "P", "P", "P"],
|
32
|
+
# ["R", "N", "B", "Q", "K", "B", "N", "R"]
|
33
|
+
# ])
|
34
|
+
# # => "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR"
|
35
|
+
#
|
36
|
+
# @example 3D board
|
37
|
+
# PiecePlacement.dump([
|
38
|
+
# [["r", "n"], ["p", "p"]],
|
39
|
+
# [["", ""], ["P", "P"]],
|
40
|
+
# [["R", "N"], ["", ""]]
|
41
|
+
# ])
|
42
|
+
# # => "rn/pp//2/PP//RN/2"
|
14
43
|
def self.dump(piece_placement)
|
15
|
-
|
16
|
-
detect_shape(piece_placement)
|
17
|
-
|
18
|
-
# Formater directement la structure en chaîne FEEN
|
44
|
+
validate_input(piece_placement)
|
19
45
|
format_placement(piece_placement)
|
20
46
|
end
|
21
47
|
|
22
|
-
#
|
48
|
+
# Validates the input piece placement structure
|
23
49
|
#
|
24
|
-
# @param piece_placement [
|
25
|
-
# @
|
26
|
-
# @
|
27
|
-
def self.
|
28
|
-
|
29
|
-
|
30
|
-
dimensions = []
|
31
|
-
current = piece_placement
|
50
|
+
# @param piece_placement [Object] Structure to validate
|
51
|
+
# @raise [ArgumentError] If the structure is invalid
|
52
|
+
# @return [void]
|
53
|
+
private_class_method def self.validate_input(piece_placement)
|
54
|
+
raise ArgumentError, format(ERRORS[:invalid_type], piece_placement.class) unless piece_placement.is_a?(Array)
|
32
55
|
|
33
|
-
|
34
|
-
while current.is_a?(Array) && !current.empty?
|
35
|
-
dimensions << current.size
|
36
|
-
|
37
|
-
# Check if all elements at this level have the same structure
|
38
|
-
validate_dimension_uniformity(current)
|
39
|
-
|
40
|
-
# Check if we've reached the leaf level (array of strings)
|
41
|
-
break if current.first.is_a?(String) ||
|
42
|
-
(current.first.is_a?(Array) && current.first.empty?)
|
43
|
-
|
44
|
-
current = current.first
|
45
|
-
end
|
46
|
-
|
47
|
-
dimensions
|
56
|
+
validate_structure_consistency(piece_placement)
|
48
57
|
end
|
49
58
|
|
50
|
-
# Validates that
|
59
|
+
# Validates that the structure is consistent (all dimensions have same size)
|
51
60
|
#
|
52
|
-
# @param
|
53
|
-
# @raise [ArgumentError] If
|
54
|
-
|
55
|
-
|
61
|
+
# @param structure [Array] Structure to validate
|
62
|
+
# @raise [ArgumentError] If structure is inconsistent
|
63
|
+
# @return [void]
|
64
|
+
private_class_method def self.validate_structure_consistency(structure)
|
65
|
+
return if structure.empty?
|
66
|
+
|
67
|
+
# Check if this is a rank (array of strings) or multi-dimensional
|
68
|
+
if structure.all?(String)
|
69
|
+
# This is a rank - validate each cell
|
70
|
+
structure.each_with_index do |cell, _index|
|
71
|
+
raise ArgumentError, format(ERRORS[:invalid_cell], cell.inspect) unless cell.is_a?(String)
|
72
|
+
end
|
73
|
+
elsif structure.all?(Array)
|
74
|
+
# This is multi-dimensional - check consistency and validate recursively
|
75
|
+
structure.each do |element|
|
76
|
+
# Recursively validate sub-structures
|
77
|
+
validate_structure_consistency(element)
|
78
|
+
end
|
79
|
+
else
|
80
|
+
# Mixed types - check for non-string elements in what should be a rank
|
81
|
+
non_string_elements = structure.reject { |element| element.is_a?(String) }
|
82
|
+
raise ArgumentError, ERRORS[:inconsistent_shape] unless non_string_elements.any?
|
56
83
|
|
57
|
-
|
58
|
-
|
84
|
+
# If we have non-string elements, report the first one as invalid cell content
|
85
|
+
first_invalid = non_string_elements.first
|
86
|
+
raise ArgumentError, format(ERRORS[:invalid_cell], first_invalid.inspect)
|
59
87
|
|
60
|
-
|
61
|
-
unless element.class == first_type
|
62
|
-
raise ArgumentError, "Inconsistent element types in dimension: #{first_type} vs #{element.class}"
|
63
|
-
end
|
88
|
+
# This shouldn't happen, but keep the original error as fallback
|
64
89
|
|
65
|
-
if element.is_a?(Array) && element.size != first_size
|
66
|
-
raise ArgumentError, "Inconsistent dimension sizes: expected #{first_size}, got #{element.size}"
|
67
|
-
end
|
68
90
|
end
|
69
91
|
end
|
70
92
|
|
@@ -72,28 +94,26 @@ module Feen
|
|
72
94
|
#
|
73
95
|
# @param placement [Array] Piece placement structure
|
74
96
|
# @return [String] FEEN piece placement string
|
75
|
-
def self.format_placement(placement)
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
end
|
97
|
+
private_class_method def self.format_placement(placement)
|
98
|
+
return "" if placement.empty?
|
99
|
+
|
100
|
+
# Check if this is a rank (1D array of strings)
|
101
|
+
return format_rank(placement) if placement.all?(String)
|
81
102
|
|
82
|
-
#
|
103
|
+
# This is multi-dimensional - determine separator depth
|
83
104
|
depth = calculate_depth(placement) - 1
|
84
105
|
separator = "/" * depth
|
85
106
|
|
86
|
-
#
|
87
|
-
|
88
|
-
elements.map { |element| format_placement(element) }.join(separator)
|
107
|
+
# Format each sub-element and join
|
108
|
+
placement.map { |element| format_placement(element) }.join(separator)
|
89
109
|
end
|
90
110
|
|
91
|
-
# Formats a rank (1D array of cells)
|
111
|
+
# Formats a rank (1D array of cells) into FEEN notation
|
92
112
|
#
|
93
|
-
# @param rank [Array] 1D array of cells
|
113
|
+
# @param rank [Array<String>] 1D array of cells
|
94
114
|
# @return [String] FEEN rank string
|
95
|
-
def self.format_rank(rank)
|
96
|
-
return "" if
|
115
|
+
private_class_method def self.format_rank(rank)
|
116
|
+
return "" if rank.empty?
|
97
117
|
|
98
118
|
result = ""
|
99
119
|
empty_count = 0
|
@@ -102,9 +122,11 @@ module Feen
|
|
102
122
|
if cell.empty?
|
103
123
|
empty_count += 1
|
104
124
|
else
|
105
|
-
# Add accumulated empty squares
|
106
|
-
|
107
|
-
|
125
|
+
# Add accumulated empty squares count
|
126
|
+
if empty_count.positive?
|
127
|
+
result += empty_count.to_s
|
128
|
+
empty_count = 0
|
129
|
+
end
|
108
130
|
|
109
131
|
# Add the piece
|
110
132
|
result += cell
|
@@ -112,16 +134,16 @@ module Feen
|
|
112
134
|
end
|
113
135
|
|
114
136
|
# Add any trailing empty squares
|
115
|
-
result += empty_count.to_s if empty_count
|
137
|
+
result += empty_count.to_s if empty_count.positive?
|
116
138
|
|
117
139
|
result
|
118
140
|
end
|
119
141
|
|
120
|
-
# Calculates the depth of a nested structure
|
142
|
+
# Calculates the depth of a nested array structure
|
121
143
|
#
|
122
144
|
# @param structure [Array] Structure to analyze
|
123
145
|
# @return [Integer] Depth of the structure
|
124
|
-
def self.calculate_depth(structure)
|
146
|
+
private_class_method def self.calculate_depth(structure)
|
125
147
|
return 0 unless structure.is_a?(Array) && !structure.empty?
|
126
148
|
|
127
149
|
if structure.first.is_a?(Array)
|
@@ -1,30 +1,42 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require_relative File.join("pieces_in_hand", "errors")
|
4
|
-
|
5
3
|
module Feen
|
6
4
|
module Dumper
|
7
5
|
# Handles conversion of pieces in hand data to FEEN notation string
|
8
6
|
module PiecesInHand
|
7
|
+
# Error messages for validation
|
8
|
+
ERRORS = {
|
9
|
+
invalid_type: "Piece at index %d must be a String, got %s",
|
10
|
+
invalid_format: "Piece at index %d must be base form only (single letter): '%s'",
|
11
|
+
has_modifiers: "Piece at index %d cannot contain modifiers: '%s'. Pieces in hand must be base form only"
|
12
|
+
}.freeze
|
13
|
+
|
9
14
|
# Converts an array of piece identifiers to a FEEN-formatted pieces in hand string
|
10
15
|
#
|
11
|
-
# @param piece_chars [Array<String>] Array of piece identifiers (e.g., ["P", "p", "B", "B", "p"
|
16
|
+
# @param piece_chars [Array<String>] Array of piece identifiers in base form only (e.g., ["P", "p", "B", "B", "p"])
|
12
17
|
# @return [String] FEEN-formatted pieces in hand string following the format:
|
13
18
|
# - Groups pieces by case: uppercase first, then lowercase, separated by "/"
|
14
19
|
# - Within each group, sorts by quantity (descending), then alphabetically (ascending)
|
15
20
|
# - Uses count notation for quantities > 1 (e.g., "3P" instead of "PPP")
|
16
|
-
# @raise [ArgumentError] If any piece identifier is invalid
|
17
|
-
#
|
21
|
+
# @raise [ArgumentError] If any piece identifier is invalid or contains modifiers
|
22
|
+
#
|
23
|
+
# @example Valid pieces in hand
|
18
24
|
# PiecesInHand.dump("P", "P", "P", "B", "B", "p", "p", "p", "p", "p")
|
19
25
|
# # => "3P2B/5p"
|
20
26
|
#
|
27
|
+
# @example Valid pieces in hand with mixed order
|
21
28
|
# PiecesInHand.dump("p", "P", "B")
|
22
29
|
# # => "BP/p"
|
23
30
|
#
|
24
|
-
#
|
31
|
+
# @example No pieces in hand
|
32
|
+
# PiecesInHand.dump()
|
25
33
|
# # => "/"
|
34
|
+
#
|
35
|
+
# @example Invalid - modifiers not allowed
|
36
|
+
# PiecesInHand.dump("+P", "p")
|
37
|
+
# # => ArgumentError: Piece at index 0 cannot contain modifiers: '+P'
|
26
38
|
def self.dump(*piece_chars)
|
27
|
-
# Validate each piece character according to
|
39
|
+
# Validate each piece character according to FEEN specification (base form only)
|
28
40
|
validated_chars = validate_piece_chars(piece_chars)
|
29
41
|
|
30
42
|
# Group pieces by case
|
@@ -43,34 +55,12 @@ module Feen
|
|
43
55
|
# @param pieces [Array<String>] Array of validated piece identifiers
|
44
56
|
# @return [Array<Array<String>, Array<String>>] Two arrays: [uppercase_pieces, lowercase_pieces]
|
45
57
|
private_class_method def self.group_pieces_by_case(pieces)
|
46
|
-
uppercase_pieces = pieces.
|
47
|
-
lowercase_pieces = pieces.
|
58
|
+
uppercase_pieces = pieces.grep(/[A-Z]/)
|
59
|
+
lowercase_pieces = pieces.grep(/[a-z]/)
|
48
60
|
|
49
61
|
[uppercase_pieces, lowercase_pieces]
|
50
62
|
end
|
51
63
|
|
52
|
-
# Determines if a piece belongs to the uppercase group
|
53
|
-
# A piece is considered uppercase if its main letter is uppercase (ignoring prefixes/suffixes)
|
54
|
-
#
|
55
|
-
# @param piece [String] Piece identifier (e.g., "P", "+P", "P'", "+P'")
|
56
|
-
# @return [Boolean] True if the piece's main letter is uppercase
|
57
|
-
private_class_method def self.piece_is_uppercase?(piece)
|
58
|
-
# Extract the main letter (skip prefixes like + or -)
|
59
|
-
main_letter = piece.gsub(/\A[+-]/, "").gsub(/'\z/, "")
|
60
|
-
main_letter.match?(/[A-Z]/)
|
61
|
-
end
|
62
|
-
|
63
|
-
# Determines if a piece belongs to the lowercase group
|
64
|
-
# A piece is considered lowercase if its main letter is lowercase (ignoring prefixes/suffixes)
|
65
|
-
#
|
66
|
-
# @param piece [String] Piece identifier (e.g., "p", "+p", "p'", "+p'")
|
67
|
-
# @return [Boolean] True if the piece's main letter is lowercase
|
68
|
-
private_class_method def self.piece_is_lowercase?(piece)
|
69
|
-
# Extract the main letter (skip prefixes like + or -)
|
70
|
-
main_letter = piece.gsub(/\A[+-]/, "").gsub(/'\z/, "")
|
71
|
-
main_letter.match?(/[a-z]/)
|
72
|
-
end
|
73
|
-
|
74
64
|
# Formats a group of pieces according to FEEN specification
|
75
65
|
#
|
76
66
|
# @param pieces [Array<String>] Array of pieces from the same case group
|
@@ -106,11 +96,11 @@ module Feen
|
|
106
96
|
end.join
|
107
97
|
end
|
108
98
|
|
109
|
-
# Validates all piece characters according to FEEN specification
|
99
|
+
# Validates all piece characters according to FEEN specification (base form only)
|
110
100
|
#
|
111
101
|
# @param piece_chars [Array<Object>] Array of piece character candidates
|
112
102
|
# @return [Array<String>] Array of validated piece characters
|
113
|
-
# @raise [ArgumentError] If any piece character is invalid
|
103
|
+
# @raise [ArgumentError] If any piece character is invalid or contains modifiers
|
114
104
|
private_class_method def self.validate_piece_chars(piece_chars)
|
115
105
|
piece_chars.each_with_index.map do |char, index|
|
116
106
|
validate_piece_char(char, index)
|
@@ -118,34 +108,22 @@ module Feen
|
|
118
108
|
end
|
119
109
|
|
120
110
|
# Validates a single piece character according to FEEN specification
|
121
|
-
#
|
122
|
-
#
|
123
|
-
# - letter must be a-z or A-Z
|
124
|
-
# - suffix can be "'"
|
111
|
+
# For pieces in hand, only base form is allowed: single letter (a-z or A-Z)
|
112
|
+
# NO modifiers (+, -, ') are allowed in pieces in hand
|
125
113
|
#
|
126
114
|
# @param char [Object] Piece character candidate
|
127
115
|
# @param index [Integer] Index of the character in the original array
|
128
116
|
# @return [String] Validated piece character
|
129
|
-
# @raise [ArgumentError] If the piece character is invalid
|
117
|
+
# @raise [ArgumentError] If the piece character is invalid or contains modifiers
|
130
118
|
private_class_method def self.validate_piece_char(char, index)
|
131
119
|
# Validate type
|
132
|
-
unless char.is_a?(
|
133
|
-
raise ::ArgumentError, format(
|
134
|
-
Errors[:invalid_type],
|
135
|
-
index: index,
|
136
|
-
type: char.class
|
137
|
-
)
|
138
|
-
end
|
120
|
+
raise ArgumentError, format(ERRORS[:invalid_type], index, char.class) unless char.is_a?(String)
|
139
121
|
|
140
|
-
#
|
141
|
-
|
142
|
-
|
143
|
-
|
144
|
-
|
145
|
-
index: index,
|
146
|
-
value: char
|
147
|
-
)
|
148
|
-
end
|
122
|
+
# Check for forbidden modifiers first (clearer error message)
|
123
|
+
raise ArgumentError, format(ERRORS[:has_modifiers], index, char) if char.match?(/[+\-']/)
|
124
|
+
|
125
|
+
# Validate format: must be exactly one letter (base form only)
|
126
|
+
raise ArgumentError, format(ERRORS[:invalid_format], index, char) unless char.match?(/\A[a-zA-Z]\z/)
|
149
127
|
|
150
128
|
char
|
151
129
|
end
|
data/lib/feen/dumper.rb
CHANGED
@@ -35,13 +35,13 @@ module Feen
|
|
35
35
|
# pieces_in_hand: [],
|
36
36
|
# games_turn: ["CHESS", "chess"]
|
37
37
|
# )
|
38
|
-
# # => "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR
|
38
|
+
# # => "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR / CHESS/chess"
|
39
39
|
#
|
40
40
|
# @param piece_placement [Array] Board position data structure representing the spatial
|
41
41
|
# distribution of pieces across the board, where each cell
|
42
42
|
# is represented by a String (or empty string for empty cells)
|
43
43
|
# @param pieces_in_hand [Array<String>] Pieces available for dropping onto the board,
|
44
|
-
# each represented as a single character string
|
44
|
+
# each represented as a single character string (base form only)
|
45
45
|
# @param games_turn [Array<String>] A two-element array where the first element is the
|
46
46
|
# active player's variant and the second is the inactive player's variant
|
47
47
|
# @return [String] Complete FEEN string representation compliant with the specification
|
@@ -1,12 +1,33 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require_relative File.join("games_turn", "valid_games_turn_pattern")
|
4
|
-
require_relative File.join("games_turn", "errors")
|
5
|
-
|
6
3
|
module Feen
|
7
4
|
module Parser
|
8
5
|
# Handles parsing of the games turn section of a FEEN string
|
9
6
|
module GamesTurn
|
7
|
+
# Error messages for games turn parsing
|
8
|
+
ERRORS = {
|
9
|
+
invalid_type: "Games turn must be a string, got %s",
|
10
|
+
empty_string: "Games turn string cannot be empty",
|
11
|
+
invalid_format: "Invalid games turn format. Expected format: UPPERCASE/lowercase or lowercase/UPPERCASE"
|
12
|
+
}.freeze
|
13
|
+
|
14
|
+
# Pattern matching the FEEN specification for games turn
|
15
|
+
# <games-turn> ::= <game-id-uppercase> "/" <game-id-lowercase>
|
16
|
+
# | <game-id-lowercase> "/" <game-id-uppercase>
|
17
|
+
VALID_GAMES_TURN_PATTERN = %r{
|
18
|
+
\A # Start of string
|
19
|
+
(?: # Non-capturing group for alternatives
|
20
|
+
(?<uppercase_first>[A-Z]+) # Named group: uppercase identifier first
|
21
|
+
/ # Separator
|
22
|
+
(?<lowercase_second>[a-z]+) # Named group: lowercase identifier second
|
23
|
+
| # OR
|
24
|
+
(?<lowercase_first>[a-z]+) # Named group: lowercase identifier first
|
25
|
+
/ # Separator
|
26
|
+
(?<uppercase_second>[A-Z]+) # Named group: uppercase identifier second
|
27
|
+
)
|
28
|
+
\z # End of string
|
29
|
+
}x
|
30
|
+
|
10
31
|
# Parses the games turn section of a FEEN string
|
11
32
|
#
|
12
33
|
# @param games_turn_str [String] FEEN games turn string
|
@@ -14,19 +35,19 @@ module Feen
|
|
14
35
|
# @raise [ArgumentError] If the input string is invalid
|
15
36
|
#
|
16
37
|
# @example Valid games turn string with uppercase first
|
17
|
-
# GamesTurn.parse("CHESS/
|
18
|
-
# # => ["CHESS", "
|
38
|
+
# GamesTurn.parse("CHESS/ogi")
|
39
|
+
# # => ["CHESS", "ogi"]
|
19
40
|
#
|
20
41
|
# @example Valid games turn string with lowercase first
|
21
|
-
# GamesTurn.parse("chess/
|
22
|
-
# # => ["chess", "
|
42
|
+
# GamesTurn.parse("chess/OGI")
|
43
|
+
# # => ["chess", "OGI"]
|
23
44
|
def self.parse(games_turn_str)
|
24
45
|
validate_input_type(games_turn_str)
|
25
46
|
|
26
|
-
match =
|
27
|
-
raise ::ArgumentError,
|
47
|
+
match = VALID_GAMES_TURN_PATTERN.match(games_turn_str)
|
48
|
+
raise ::ArgumentError, ERRORS[:invalid_format] unless match
|
28
49
|
|
29
|
-
extract_game_identifiers(
|
50
|
+
extract_game_identifiers(match)
|
30
51
|
end
|
31
52
|
|
32
53
|
# Validates that the input is a non-empty string
|
@@ -35,22 +56,21 @@ module Feen
|
|
35
56
|
# @raise [ArgumentError] If input is not a string or is empty
|
36
57
|
# @return [void]
|
37
58
|
private_class_method def self.validate_input_type(str)
|
38
|
-
raise ::ArgumentError, format(
|
39
|
-
raise ::ArgumentError,
|
59
|
+
raise ::ArgumentError, format(ERRORS[:invalid_type], str.class) unless str.is_a?(::String)
|
60
|
+
raise ::ArgumentError, ERRORS[:empty_string] if str.empty?
|
40
61
|
end
|
41
62
|
|
42
63
|
# Extracts game identifiers from regexp match captures
|
43
64
|
#
|
44
|
-
# @param
|
45
|
-
# @param lowercase_second [String, nil] Lowercase identifier if it comes second
|
46
|
-
# @param lowercase_first [String, nil] Lowercase identifier if it comes first
|
47
|
-
# @param uppercase_second [String, nil] Uppercase identifier if it comes second
|
65
|
+
# @param match [MatchData] Regexp match data with named captures
|
48
66
|
# @return [Array<String>] Array containing [active_player, inactive_player]
|
49
|
-
private_class_method def self.extract_game_identifiers(
|
50
|
-
|
51
|
-
|
67
|
+
private_class_method def self.extract_game_identifiers(match)
|
68
|
+
captures = match.named_captures
|
69
|
+
|
70
|
+
if captures["uppercase_first"]
|
71
|
+
[captures["uppercase_first"], captures["lowercase_second"]]
|
52
72
|
else
|
53
|
-
[lowercase_first, uppercase_second]
|
73
|
+
[captures["lowercase_first"], captures["uppercase_second"]]
|
54
74
|
end
|
55
75
|
end
|
56
76
|
end
|