feen 5.0.0.beta0 → 5.0.0.beta2
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/LICENSE.md +1 -1
- data/README.md +139 -51
- data/lib/feen/converter/from_fen.rb +170 -0
- data/lib/feen/converter/to_fen.rb +153 -0
- data/lib/feen/converter.rb +16 -0
- data/lib/feen/dumper/games_turn.rb +92 -0
- data/lib/feen/dumper/piece_placement.rb +104 -67
- data/lib/feen/dumper/pieces_in_hand.rb +44 -16
- data/lib/feen/dumper.rb +43 -30
- data/lib/feen/parser/games_turn.rb +136 -0
- data/lib/feen/parser/piece_placement.rb +204 -44
- data/lib/feen/parser/pieces_in_hand.rb +63 -22
- data/lib/feen/parser.rb +39 -27
- data/lib/feen/sanitizer.rb +119 -0
- data/lib/feen.rb +91 -46
- metadata +12 -10
- data/lib/feen/parser/board_shape.rb +0 -37
@@ -0,0 +1,92 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Feen
|
4
|
+
module Dumper
|
5
|
+
# Handles conversion of games turn data structure to FEEN notation string
|
6
|
+
module GamesTurn
|
7
|
+
ERRORS = {
|
8
|
+
missing_key: "Missing required key in games_turn: %s",
|
9
|
+
invalid_type: "Invalid type for games_turn[%s]: expected String, got %s",
|
10
|
+
empty_string: "Empty string for games_turn[%s]",
|
11
|
+
casing_requirement: "One game must be uppercase and the other lowercase",
|
12
|
+
invalid_chars: "Game identifiers must contain only alphabetic characters (a-z, A-Z)"
|
13
|
+
}.freeze
|
14
|
+
|
15
|
+
REQUIRED_KEYS = %i[active_player inactive_player].freeze
|
16
|
+
|
17
|
+
# Converts the internal games turn representation to a FEEN string
|
18
|
+
#
|
19
|
+
# @param games_turn [Hash] Hash containing game turn information
|
20
|
+
# @return [String] FEEN-formatted games turn string
|
21
|
+
def self.dump(games_turn)
|
22
|
+
validate_games_turn(games_turn)
|
23
|
+
|
24
|
+
# Format is <active_player>/<inactive_player>
|
25
|
+
"#{games_turn[:active_player]}/#{games_turn[:inactive_player]}"
|
26
|
+
end
|
27
|
+
|
28
|
+
# Validates the games turn data structure
|
29
|
+
#
|
30
|
+
# @param games_turn [Hash] The games turn data to validate
|
31
|
+
# @raise [ArgumentError] If the games turn data is invalid
|
32
|
+
# @return [Boolean] true if the validation passes
|
33
|
+
def self.validate_games_turn(games_turn)
|
34
|
+
validate_structure(games_turn)
|
35
|
+
validate_casing(games_turn)
|
36
|
+
validate_character_set(games_turn)
|
37
|
+
|
38
|
+
true
|
39
|
+
end
|
40
|
+
|
41
|
+
# Validates the basic structure of games_turn
|
42
|
+
#
|
43
|
+
# @param games_turn [Hash] The games turn data to validate
|
44
|
+
# @raise [ArgumentError] If the structure is invalid
|
45
|
+
# @return [void]
|
46
|
+
private_class_method def self.validate_structure(games_turn)
|
47
|
+
REQUIRED_KEYS.each do |key|
|
48
|
+
raise ArgumentError, format(ERRORS[:missing_key], key) unless games_turn.key?(key)
|
49
|
+
|
50
|
+
unless games_turn[key].is_a?(String)
|
51
|
+
raise ArgumentError, format(ERRORS[:invalid_type], key, games_turn[key].class)
|
52
|
+
end
|
53
|
+
|
54
|
+
raise ArgumentError, format(ERRORS[:empty_string], key) if games_turn[key].empty?
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
# Validates the casing requirement (one uppercase, one lowercase)
|
59
|
+
#
|
60
|
+
# @param games_turn [Hash] The games turn data to validate
|
61
|
+
# @raise [ArgumentError] If the casing requirement is not met
|
62
|
+
# @return [void]
|
63
|
+
private_class_method def self.validate_casing(games_turn)
|
64
|
+
active_has_uppercase = games_turn[:active_player].match?(/[A-Z]/)
|
65
|
+
inactive_has_uppercase = games_turn[:inactive_player].match?(/[A-Z]/)
|
66
|
+
|
67
|
+
# Ensure exactly one has uppercase
|
68
|
+
raise ArgumentError, ERRORS[:casing_requirement] if active_has_uppercase == inactive_has_uppercase
|
69
|
+
|
70
|
+
# Check that uppercase game is all caps and lowercase game has no caps
|
71
|
+
if active_has_uppercase && games_turn[:active_player].match?(/[a-z]/)
|
72
|
+
raise ArgumentError, "Active game has mixed case: #{games_turn[:active_player]}"
|
73
|
+
end
|
74
|
+
|
75
|
+
return unless inactive_has_uppercase && games_turn[:inactive_player].match?(/[a-z]/)
|
76
|
+
|
77
|
+
raise ArgumentError, "Inactive game has mixed case: #{games_turn[:inactive_player]}"
|
78
|
+
end
|
79
|
+
|
80
|
+
# Validates that identifiers only contain allowed characters
|
81
|
+
#
|
82
|
+
# @param games_turn [Hash] The games turn data to validate
|
83
|
+
# @raise [ArgumentError] If invalid characters are present
|
84
|
+
# @return [void]
|
85
|
+
private_class_method def self.validate_character_set(games_turn)
|
86
|
+
REQUIRED_KEYS.each do |key|
|
87
|
+
raise ArgumentError, ERRORS[:invalid_chars] unless games_turn[key].match?(/\A[a-zA-Z]+\z/)
|
88
|
+
end
|
89
|
+
end
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
@@ -2,83 +2,120 @@
|
|
2
2
|
|
3
3
|
module Feen
|
4
4
|
module Dumper
|
5
|
-
#
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
# 62 => "兵",
|
35
|
-
# 64 => "炮",
|
36
|
-
# 70 => "炮",
|
37
|
-
# 81 => "俥",
|
38
|
-
# 82 => "傌",
|
39
|
-
# 83 => "相",
|
40
|
-
# 84 => "仕",
|
41
|
-
# 85 => "帥",
|
42
|
-
# 86 => "仕",
|
43
|
-
# 87 => "相",
|
44
|
-
# 88 => "傌",
|
45
|
-
# 89 => "俥"
|
46
|
-
# }
|
47
|
-
# ).to_s # => "車,馬,象,士,將,士,象,馬,車/9/1,砲,5,砲,1/卒,1,卒,1,卒,1,卒,1,卒/9/9/兵,1,兵,1,兵,1,兵,1,兵/1,炮,5,炮,1/9/俥,傌,相,仕,帥,仕,相,傌,俥"
|
48
|
-
class PiecePlacement
|
49
|
-
# @param indexes [Array] The shape of the board.
|
50
|
-
# @param piece_placement [Hash] The index of each piece on the board.
|
51
|
-
def initialize(indexes, piece_placement = {})
|
52
|
-
@indexes = indexes
|
53
|
-
@squares = ::Array.new(length) { |i| piece_placement.fetch(i, nil) }
|
5
|
+
# Handles conversion of piece placement data structure to FEEN notation string
|
6
|
+
module PiecePlacement
|
7
|
+
# Error messages
|
8
|
+
ERRORS = {
|
9
|
+
empty_input: "Piece placement cannot be empty"
|
10
|
+
}.freeze
|
11
|
+
|
12
|
+
# Empty string for initialization
|
13
|
+
EMPTY_STRING = ""
|
14
|
+
|
15
|
+
# Dimension separator character
|
16
|
+
DIMENSION_SEPARATOR = "/"
|
17
|
+
|
18
|
+
# Converts the internal piece placement representation to a FEEN string
|
19
|
+
#
|
20
|
+
# @param piece_placement [Array] Hierarchical array structure representing the board
|
21
|
+
# @return [String] FEEN-formatted piece placement string
|
22
|
+
# @example
|
23
|
+
# piece_placement = [[{id: 'r'}, {id: 'n'}, nil, nil]]
|
24
|
+
# PiecePlacement.dump(piece_placement)
|
25
|
+
# # => "rn2"
|
26
|
+
def self.dump(piece_placement)
|
27
|
+
return EMPTY_STRING if piece_placement.nil? || piece_placement.empty?
|
28
|
+
|
29
|
+
# Step 1: Calculate the total depth of the structure
|
30
|
+
depth = calculate_depth(piece_placement)
|
31
|
+
|
32
|
+
# Step 2: Convert the structure to a string
|
33
|
+
dump_recursive(piece_placement, depth)
|
54
34
|
end
|
55
35
|
|
56
|
-
#
|
57
|
-
|
58
|
-
|
36
|
+
# Calculates the maximum depth of the data structure
|
37
|
+
#
|
38
|
+
# @param data [Array] Data structure to evaluate
|
39
|
+
# @return [Integer] Maximum depth
|
40
|
+
def self.calculate_depth(data)
|
41
|
+
return 0 unless data.is_a?(Array) && !data.empty?
|
42
|
+
|
43
|
+
if data.first.is_a?(Array)
|
44
|
+
1 + calculate_depth(data.first)
|
45
|
+
else
|
46
|
+
1
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
# Recursively converts the structure to FEEN notation
|
51
|
+
#
|
52
|
+
# @param data [Array] Data to convert
|
53
|
+
# @param max_depth [Integer] Maximum depth of the structure
|
54
|
+
# @param current_depth [Integer] Current recursion depth
|
55
|
+
# @return [String] FEEN representation
|
56
|
+
def self.dump_recursive(data, max_depth, current_depth = 1)
|
57
|
+
return EMPTY_STRING if data.nil? || data.empty?
|
58
|
+
|
59
|
+
if data.first.is_a?(Array)
|
60
|
+
parts = data.map { |subdata| dump_recursive(subdata, max_depth, current_depth + 1) }
|
61
|
+
|
62
|
+
# The number of separators depends on the current depth
|
63
|
+
# The lowest level uses a single '/', then increases progressively
|
64
|
+
separator_count = max_depth - current_depth
|
65
|
+
separator = DIMENSION_SEPARATOR * separator_count
|
66
|
+
|
67
|
+
parts.join(separator)
|
68
|
+
else
|
69
|
+
# This is a simple row (rank)
|
70
|
+
dump_rank(data)
|
71
|
+
end
|
59
72
|
end
|
60
73
|
|
61
|
-
|
74
|
+
# Converts a single rank (row/array of cells) to FEEN notation
|
75
|
+
#
|
76
|
+
# @param cells [Array] Array of cell values (nil for empty, hash for piece)
|
77
|
+
# @return [String] FEEN representation of this rank
|
78
|
+
# @example
|
79
|
+
# cells = [{id: 'r'}, {id: 'n'}, nil, nil]
|
80
|
+
# PiecePlacement.dump_rank(cells)
|
81
|
+
# # => "rn2"
|
82
|
+
def self.dump_rank(cells)
|
83
|
+
return EMPTY_STRING if cells.nil? || cells.empty?
|
62
84
|
|
63
|
-
|
64
|
-
|
85
|
+
# Use chunk_while to group consecutive empty/non-empty cells
|
86
|
+
cells.chunk_while { |a, b| a.nil? == b.nil? }.map do |chunk|
|
87
|
+
if chunk.first.nil?
|
88
|
+
# Group of empty cells -> count
|
89
|
+
chunk.size.to_s
|
90
|
+
else
|
91
|
+
# Group of pieces -> concatenate FEEN representations
|
92
|
+
chunk.map { |cell| dump_cell(cell) }.join(EMPTY_STRING)
|
93
|
+
end
|
94
|
+
end.join(EMPTY_STRING)
|
65
95
|
end
|
66
96
|
|
67
|
-
|
68
|
-
|
97
|
+
# Converts a single piece cell to its FEEN representation
|
98
|
+
#
|
99
|
+
# @param cell [Hash] Hash with :id and optional :prefix and :suffix
|
100
|
+
# @return [String] FEEN representation of this piece
|
101
|
+
# @example
|
102
|
+
# cell = {id: 'P', suffix: '='}
|
103
|
+
# PiecePlacement.dump_cell(cell)
|
104
|
+
# # => "P="
|
105
|
+
def self.dump_cell(cell)
|
106
|
+
return EMPTY_STRING if cell.nil?
|
69
107
|
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
108
|
+
# Combine prefix (if any) + piece identifier + suffix (if any)
|
109
|
+
[
|
110
|
+
cell[:prefix],
|
111
|
+
cell[:id],
|
112
|
+
cell[:suffix]
|
113
|
+
].compact.join(EMPTY_STRING)
|
75
114
|
end
|
76
115
|
|
77
|
-
def
|
78
|
-
|
79
|
-
|
80
|
-
.join(",")
|
81
|
-
.gsub(/1,[1,]*1/) { |str| str.split(",").length }
|
116
|
+
def self.dump_dimension_group(group)
|
117
|
+
max_depth = calculate_depth(group)
|
118
|
+
dump_recursive(group, max_depth)
|
82
119
|
end
|
83
120
|
end
|
84
121
|
end
|
@@ -2,26 +2,54 @@
|
|
2
2
|
|
3
3
|
module Feen
|
4
4
|
module Dumper
|
5
|
-
#
|
5
|
+
# Handles conversion of pieces in hand data structure to FEEN notation string
|
6
6
|
module PiecesInHand
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
|
11
|
-
|
12
|
-
|
13
|
-
|
7
|
+
EMPTY_RESULT = "-"
|
8
|
+
ERRORS = {
|
9
|
+
invalid_piece: "Invalid piece at index %d: must be a Hash with an :id key",
|
10
|
+
invalid_id: "Invalid piece ID at index %d: must be a single alphabetic character",
|
11
|
+
prefix_not_allowed: "Prefix is not allowed for pieces in hand at index %d",
|
12
|
+
suffix_not_allowed: "Suffix is not allowed for pieces in hand at index %d"
|
13
|
+
}.freeze
|
14
|
+
|
15
|
+
# Converts the internal pieces in hand representation to a FEEN string
|
14
16
|
#
|
15
|
-
# @
|
16
|
-
#
|
17
|
-
|
17
|
+
# @param pieces_in_hand [Array<Hash>] Array of piece hashes
|
18
|
+
# @return [String] FEEN-formatted pieces in hand string
|
19
|
+
def self.dump(pieces_in_hand)
|
20
|
+
# If no pieces in hand, return a single hyphen
|
21
|
+
return EMPTY_RESULT if pieces_in_hand.nil? || pieces_in_hand.empty?
|
22
|
+
|
23
|
+
# Validate all pieces have the required structure
|
24
|
+
validate_pieces(pieces_in_hand)
|
25
|
+
|
26
|
+
# Sort pieces in ASCII lexicographic order by their ID
|
27
|
+
pieces_in_hand
|
28
|
+
.sort_by { |piece| piece[:id] }
|
29
|
+
.map { |piece| piece[:id] }
|
30
|
+
.join
|
31
|
+
end
|
32
|
+
|
33
|
+
# Validates the structure of each piece in the array
|
18
34
|
#
|
19
|
-
# @
|
20
|
-
|
21
|
-
|
35
|
+
# @param pieces [Array<Hash>] Array of piece hashes to validate
|
36
|
+
# @raise [ArgumentError] If any piece has an invalid structure
|
37
|
+
# @return [void]
|
38
|
+
def self.validate_pieces(pieces)
|
39
|
+
pieces.each_with_index do |piece, index|
|
40
|
+
# Check basic piece structure
|
41
|
+
raise ArgumentError, format(ERRORS[:invalid_piece], index) unless piece.is_a?(Hash) && piece.key?(:id)
|
42
|
+
|
43
|
+
# Validate piece ID
|
44
|
+
unless piece[:id].is_a?(String) && piece[:id].match?(/\A[a-zA-Z]\z/)
|
45
|
+
raise ArgumentError, format(ERRORS[:invalid_id], index)
|
46
|
+
end
|
47
|
+
|
48
|
+
# Ensure no prefix or suffix is present
|
49
|
+
raise ArgumentError, format(ERRORS[:prefix_not_allowed], index) if piece.key?(:prefix) && !piece[:prefix].nil?
|
22
50
|
|
23
|
-
|
24
|
-
|
51
|
+
raise ArgumentError, format(ERRORS[:suffix_not_allowed], index) if piece.key?(:suffix) && !piece[:suffix].nil?
|
52
|
+
end
|
25
53
|
end
|
26
54
|
end
|
27
55
|
end
|
data/lib/feen/dumper.rb
CHANGED
@@ -1,42 +1,55 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require_relative File.join("dumper", "games_turn")
|
3
4
|
require_relative File.join("dumper", "piece_placement")
|
4
5
|
require_relative File.join("dumper", "pieces_in_hand")
|
5
6
|
|
6
7
|
module Feen
|
7
|
-
#
|
8
|
+
# Module responsible for converting internal data structures to FEEN notation strings
|
8
9
|
module Dumper
|
9
|
-
#
|
10
|
+
# Converts a complete position data structure to a FEEN string
|
10
11
|
#
|
11
|
-
# @param
|
12
|
-
# @
|
13
|
-
# @
|
14
|
-
# @
|
15
|
-
#
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
#
|
27
|
-
# }
|
28
|
-
# )
|
29
|
-
# # => "3,s,k,s,3/9/4,+P,4/9/7,+B,1/9/9/9/9 s S,b,g*4,n*4,p*17,r*2,s"
|
12
|
+
# @param position [Hash] Hash containing the complete position data
|
13
|
+
# @option position [Array] :piece_placement Board position data
|
14
|
+
# @option position [Hash] :games_turn Games and turn data
|
15
|
+
# @option position [Array<Hash>] :pieces_in_hand Pieces in hand data
|
16
|
+
# @return [String] Complete FEEN string representation
|
17
|
+
def self.dump(position)
|
18
|
+
validate_position(position)
|
19
|
+
|
20
|
+
[
|
21
|
+
PiecePlacement.dump(position[:piece_placement]),
|
22
|
+
GamesTurn.dump(position[:games_turn]),
|
23
|
+
PiecesInHand.dump(position[:pieces_in_hand])
|
24
|
+
].join(" ")
|
25
|
+
end
|
26
|
+
|
27
|
+
# Validates the position data structure
|
30
28
|
#
|
31
|
-
# @
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
29
|
+
# @param position [Hash] Position data to validate
|
30
|
+
# @raise [ArgumentError] If the position data is invalid
|
31
|
+
# @return [void]
|
32
|
+
def self.validate_position(position)
|
33
|
+
raise ArgumentError, "Position must be a Hash, got #{position.class}" unless position.is_a?(Hash)
|
34
|
+
|
35
|
+
# Check for required keys
|
36
|
+
required_keys = %i[piece_placement games_turn pieces_in_hand]
|
37
|
+
missing_keys = required_keys - position.keys
|
38
|
+
|
39
|
+
raise ArgumentError, "Missing required keys in position: #{missing_keys.join(', ')}" unless missing_keys.empty?
|
40
|
+
|
41
|
+
# Validate types of values
|
42
|
+
unless position[:piece_placement].is_a?(Array)
|
43
|
+
raise ArgumentError, "piece_placement must be an Array, got #{position[:piece_placement].class}"
|
44
|
+
end
|
45
|
+
|
46
|
+
unless position[:games_turn].is_a?(Hash)
|
47
|
+
raise ArgumentError, "games_turn must be a Hash, got #{position[:games_turn].class}"
|
48
|
+
end
|
49
|
+
|
50
|
+
return if position[:pieces_in_hand].is_a?(Array)
|
51
|
+
|
52
|
+
raise ArgumentError, "pieces_in_hand must be an Array, got #{position[:pieces_in_hand].class}"
|
40
53
|
end
|
41
54
|
end
|
42
55
|
end
|
@@ -0,0 +1,136 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Feen
|
4
|
+
module Parser
|
5
|
+
# Handles parsing of the games turn section of a FEEN string
|
6
|
+
module GamesTurn
|
7
|
+
ERRORS = {
|
8
|
+
invalid_type: "Games turn must be a string, got %s",
|
9
|
+
empty_string: "Games turn string cannot be empty",
|
10
|
+
separator: "Games turn must contain exactly one '/' separator",
|
11
|
+
mixed_casing: "%s game has mixed case: %s",
|
12
|
+
casing_requirement: "One game must use uppercase letters and the other lowercase letters",
|
13
|
+
invalid_chars: "Invalid characters in %s game identifier: %s",
|
14
|
+
empty_identifier: "%s game identifier cannot be empty"
|
15
|
+
}.freeze
|
16
|
+
|
17
|
+
# Parses the games turn section of a FEEN string
|
18
|
+
#
|
19
|
+
# @param games_turn_str [String] FEEN games turn string
|
20
|
+
# @return [Hash] Hash containing game turn information
|
21
|
+
# @raise [ArgumentError] If the input string is invalid
|
22
|
+
def self.parse(games_turn_str)
|
23
|
+
validate_games_turn_string(games_turn_str)
|
24
|
+
|
25
|
+
# Split by the forward slash
|
26
|
+
parts = games_turn_str.split("/")
|
27
|
+
active_player = parts[0]
|
28
|
+
inactive_player = parts[1]
|
29
|
+
|
30
|
+
# Determine casing
|
31
|
+
active_uppercase = contains_uppercase?(active_player)
|
32
|
+
|
33
|
+
# Build result hash
|
34
|
+
{
|
35
|
+
active_player: active_player,
|
36
|
+
inactive_player: inactive_player,
|
37
|
+
uppercase_game: active_uppercase ? active_player : inactive_player,
|
38
|
+
lowercase_game: active_uppercase ? inactive_player : active_player,
|
39
|
+
active_player_casing: active_uppercase ? :uppercase : :lowercase
|
40
|
+
}
|
41
|
+
end
|
42
|
+
|
43
|
+
# Validates the games turn string for syntax and semantics
|
44
|
+
#
|
45
|
+
# @param str [String] FEEN games turn string
|
46
|
+
# @raise [ArgumentError] If the string is invalid
|
47
|
+
# @return [void]
|
48
|
+
def self.validate_games_turn_string(str)
|
49
|
+
validate_basic_structure(str)
|
50
|
+
|
51
|
+
parts = str.split("/")
|
52
|
+
validate_game_identifiers(parts)
|
53
|
+
validate_casing_requirements(parts)
|
54
|
+
end
|
55
|
+
|
56
|
+
# Validates the basic structure of the games turn string
|
57
|
+
#
|
58
|
+
# @param str [String] FEEN games turn string
|
59
|
+
# @raise [ArgumentError] If the structure is invalid
|
60
|
+
# @return [void]
|
61
|
+
private_class_method def self.validate_basic_structure(str)
|
62
|
+
raise ArgumentError, format(ERRORS[:invalid_type], str.class) unless str.is_a?(String)
|
63
|
+
|
64
|
+
raise ArgumentError, ERRORS[:empty_string] if str.empty?
|
65
|
+
|
66
|
+
# Check for exactly one separator '/'
|
67
|
+
return if str.count("/") == 1
|
68
|
+
|
69
|
+
raise ArgumentError, ERRORS[:separator]
|
70
|
+
end
|
71
|
+
|
72
|
+
# Validates the individual game identifiers
|
73
|
+
#
|
74
|
+
# @param parts [Array<String>] Split game identifiers
|
75
|
+
# @raise [ArgumentError] If any identifier is invalid
|
76
|
+
# @return [void]
|
77
|
+
private_class_method def self.validate_game_identifiers(parts)
|
78
|
+
parts.each_with_index do |game_id, idx|
|
79
|
+
position = idx == 0 ? "active" : "inactive"
|
80
|
+
|
81
|
+
raise ArgumentError, format(ERRORS[:empty_identifier], position.capitalize) if game_id.nil? || game_id.empty?
|
82
|
+
|
83
|
+
unless game_id.match?(/\A[a-zA-Z]+\z/)
|
84
|
+
invalid_chars = game_id.scan(/[^a-zA-Z]/).uniq.join(", ")
|
85
|
+
raise ArgumentError, format(ERRORS[:invalid_chars], position, invalid_chars)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
89
|
+
|
90
|
+
# Validates the casing requirements (one uppercase, one lowercase)
|
91
|
+
#
|
92
|
+
# @param parts [Array<String>] Split game identifiers
|
93
|
+
# @raise [ArgumentError] If casing requirements aren't met
|
94
|
+
# @return [void]
|
95
|
+
private_class_method def self.validate_casing_requirements(parts)
|
96
|
+
active_uppercase = contains_uppercase?(parts[0])
|
97
|
+
inactive_uppercase = contains_uppercase?(parts[1])
|
98
|
+
|
99
|
+
raise ArgumentError, ERRORS[:casing_requirement] if active_uppercase == inactive_uppercase
|
100
|
+
|
101
|
+
# Verify consistent casing in each identifier
|
102
|
+
if active_uppercase && contains_lowercase?(parts[0])
|
103
|
+
raise ArgumentError, format(ERRORS[:mixed_casing], "Active", parts[0])
|
104
|
+
end
|
105
|
+
|
106
|
+
if inactive_uppercase && contains_lowercase?(parts[1])
|
107
|
+
raise ArgumentError, format(ERRORS[:mixed_casing], "Inactive", parts[1])
|
108
|
+
end
|
109
|
+
|
110
|
+
if !active_uppercase && contains_uppercase?(parts[0])
|
111
|
+
raise ArgumentError, format(ERRORS[:mixed_casing], "Active", parts[0])
|
112
|
+
end
|
113
|
+
|
114
|
+
return unless !inactive_uppercase && contains_uppercase?(parts[1])
|
115
|
+
|
116
|
+
raise ArgumentError, format(ERRORS[:mixed_casing], "Inactive", parts[1])
|
117
|
+
end
|
118
|
+
|
119
|
+
# Checks if a string contains any uppercase letters
|
120
|
+
#
|
121
|
+
# @param str [String] String to check
|
122
|
+
# @return [Boolean] True if the string contains uppercase letters
|
123
|
+
def self.contains_uppercase?(str)
|
124
|
+
str.match?(/[A-Z]/)
|
125
|
+
end
|
126
|
+
|
127
|
+
# Checks if a string contains any lowercase letters
|
128
|
+
#
|
129
|
+
# @param str [String] String to check
|
130
|
+
# @return [Boolean] True if the string contains lowercase letters
|
131
|
+
def self.contains_lowercase?(str)
|
132
|
+
str.match?(/[a-z]/)
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
136
|
+
end
|