Skip to content

Commit

Permalink
Improve parser tests
Browse files Browse the repository at this point in the history
  • Loading branch information
pdil committed Aug 3, 2024
1 parent 7126e1c commit 3c13f56
Show file tree
Hide file tree
Showing 6 changed files with 132 additions and 82 deletions.
11 changes: 6 additions & 5 deletions Sources/ChessKit/Parsers/PGNParser+Regex.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,22 @@
extension PGNParser {

/// Contains useful regex strings for PGN parsing.
struct Regex {
struct Pattern {
// tag pair components
static let tags = #"\[[^\]]+\]"#
static let tagPair = #"\[([^"]+?)\s"([^"]+)"\]"#

// move text
static let moveText = #"\d{1,}\.{1,3}\s?(([Oo0]-[Oo0](-[Oo0])?|[KQRBN]?[a-h]?[1-8]?x?[a-h][1-8](\=[QRBN])?[+#]?)([\?!]{1,2})?(\s?\$\d)?(\s?\{.+?\})?(\s(1-0|0-1|1\/2-1\/2))?\s?){1,2}"#
static let moveNumber = #"^\d{1,}"#
static let singleMove = "(\(castle)?|\(move)?)(\\s?\(annotation))?(\\s?\(comment))?"

// move pair components
static let castle = #"[Oo0]-[Oo0](-[Oo0])"#
static let move = #"[KQRBN]?[a-h]?[1-8]?x?[a-h][1-8](\=[QRBN])?[+#]"#
static let annotation = #"\$\d"#
static let comment = #"\{.+?\}"#
static let result = #"(1-0|0-1|1\/2-1\/2)"#

static let full = #"\d{1,}\.{1,3}\s?(([Oo0]-[Oo0](-[Oo0])?|[KQRBN]?[a-h]?[1-8]?x?[a-h][1-8](\=[QRBN])?[+#]?)([\?!]{1,2})?(\s?\$\d)?(\s?\{.+?\})?(\s(1-0|0-1|1\/2-1\/2))?\s?){1,2}"#

static let moveNumber = #"^\d{1,}"#
}

}
84 changes: 37 additions & 47 deletions Sources/ChessKit/Parsers/PGNParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,8 @@

import Foundation

/// Positional assessments.
// periphery:ignore
enum PositionAnnotation {
// <TBD>
}

/// Parses and converts the Portable Game Notation (PGN)
/// of a chess game.
///
public enum PGNParser {

/// Contains the contents of a single parsed move pair.
Expand Down Expand Up @@ -58,44 +51,43 @@ public enum PGNParser {

// tags

let tags: [(String, String)]? = try? NSRegularExpression(pattern: Regex.tags)
let tags: [(String, String)]? = try? NSRegularExpression(pattern: Pattern.tags)
.matches(in: processedPGN, range: range)
.map {
NSString(string: pgn).substring(with: $0.range)
.trimmingCharacters(in: .whitespacesAndNewlines)
}

.compactMap { tag in
let tagRange = NSRange(0..<tag.utf16.count)

let matches = try? NSRegularExpression(pattern: Regex.tagPair)
.matches(in: tag, range: tagRange)

if let matches,
matches.count >= 1,
matches[0].numberOfRanges >= 3 {
let key = matches[0].range(at: 1)
let value = matches[0].range(at: 2)

return (
NSString(string: tag).substring(with: key)
.trimmingCharacters(in: .whitespacesAndNewlines),
NSString(string: tag).substring(with: value)
.trimmingCharacters(in: .whitespacesAndNewlines)
)
} else {
return nil
let tagRange = NSRange(0..<tag.utf16.count)

let matches = try? NSRegularExpression(pattern: Pattern.tagPair)
.matches(in: tag, range: tagRange)

if let matches,
matches.count >= 1,
matches[0].numberOfRanges >= 3 {
let key = matches[0].range(at: 1)
let value = matches[0].range(at: 2)

return (
NSString(string: tag).substring(with: key)
.trimmingCharacters(in: .whitespacesAndNewlines),
NSString(string: tag).substring(with: value)
.trimmingCharacters(in: .whitespacesAndNewlines)
)
} else {
return nil
}
}
}

let parsedTags = parsed(tags: Dictionary<String, String>(tags ?? []) { a, _ in a })

// movetext

let moves: [String]
let moveText: [String]

do {
moves = try NSRegularExpression(pattern: Regex.full)
moveText = try NSRegularExpression(pattern: Pattern.moveText)
.matches(in: processedPGN, range: range)
.map {
NSString(string: pgn).substring(with: $0.range)
Expand All @@ -105,35 +97,33 @@ public enum PGNParser {
return nil
}

let parsedMoves = moves.compactMap { move -> ParsedMove? in
let parsedMoves = moveText.compactMap { move -> ParsedMove? in
let range = NSRange(0..<move.utf16.count)

guard let moveNumberRange = move.range(of: Regex.moveNumber, options: .regularExpression),
guard let moveNumberRange = move.range(of: Pattern.moveNumber, options: .regularExpression),
let moveNumber = Int(move[moveNumberRange])
else {
return nil
}

let singleMoveRegex = "(\(Regex.castle)?|\(Regex.move)?)(\\s?\(Regex.annotation))?(\\s?\(Regex.comment))?"

guard let m = try? NSRegularExpression(pattern: singleMoveRegex)
guard let m = try? NSRegularExpression(pattern: Pattern.singleMove)
.matches(in: move, range: range)
.map({ NSString(string: move).substring(with: $0.range) }),
m.count >= 1 && m.count <= 2
else {
return nil
}

let whiteAnnotation = try? NSRegularExpression(pattern: Regex.annotation)
let whiteAnnotation = try? NSRegularExpression(pattern: Pattern.annotation)
.matches(in: m[0], range: NSRange(0..<m[0].utf16.count))
.map {
Move.Assessment(rawValue: NSString(string: m[0]).substring(with: $0.range)) ?? .null
.compactMap {
Move.Assessment(rawValue: NSString(string: m[0]).substring(with: $0.range))
}
.first ?? .null

let whiteComment = try? NSRegularExpression(pattern: Regex.comment)
let whiteComment = try? NSRegularExpression(pattern: Pattern.comment)
.matches(in: m[0], range: NSRange(0..<m[0].utf16.count))
.map {
.compactMap {
NSString(string: m[0]).substring(with: $0.range)
.replacingOccurrences(of: "{", with: "")
.replacingOccurrences(of: "}", with: "")
Expand All @@ -144,24 +134,24 @@ public enum PGNParser {
var blackComment: String?

if m.count == 2 {
blackAnnotation = try? NSRegularExpression(pattern: Regex.annotation)
blackAnnotation = try? NSRegularExpression(pattern: Pattern.annotation)
.matches(in: m[1], range: NSRange(0..<m[1].utf16.count))
.map {
Move.Assessment(rawValue: NSString(string: m[1]).substring(with: $0.range)) ?? .null
.compactMap {
Move.Assessment(rawValue: NSString(string: m[1]).substring(with: $0.range))
}
.first ?? .null

blackComment = try? NSRegularExpression(pattern: Regex.comment)
blackComment = try? NSRegularExpression(pattern: Pattern.comment)
.matches(in: m[1], range: NSRange(0..<m[1].utf16.count))
.map {
.compactMap {
NSString(string: m[1]).substring(with: $0.range)
.replacingOccurrences(of: "{", with: "")
.replacingOccurrences(of: "}", with: "")
}
.first ?? ""
}

let result = try? NSRegularExpression(pattern: Regex.result)
let result = try? NSRegularExpression(pattern: Pattern.result)
.matches(in: move, range: range)
.map {
NSString(string: move).substring(with: $0.range)
Expand Down
28 changes: 22 additions & 6 deletions Tests/ChessKitTests/Parsers/EngineLANParserTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,30 @@ import XCTest

class EngineLANParserTests: XCTestCase {

func testCapture() {
let position = Position(fen: "8/8/8/4p3/3P4/8/8/8 w - - 0 1")!
let move = EngineLANParser.parse(move: "d4e5", for: .white, in: position)

let capturedPiece = Piece(.pawn, color: .black, square: .e5)
XCTAssertEqual(move?.result, .capture(capturedPiece))
}

func testCastling() {
let p1 = Position(fen: "r3k3/8/8/8/8/8/8/4K2R w Kq - 0 1")!
let shortCastle = EngineLANParser.parse(move: "e1g1", for: .white, in: p1)
XCTAssertEqual(shortCastle?.result, .castle(.wK))
let p1 = Position(fen: "8/8/8/8/8/8/8/4K2R w KQ - 0 1")!
let wShortCastle = EngineLANParser.parse(move: "e1g1", for: .white, in: p1)
XCTAssertEqual(wShortCastle?.result, .castle(.wK))

let p2 = Position(fen: "8/8/8/8/8/8/8/R3K3 w KQ - 0 1")!
let wLongCastle = EngineLANParser.parse(move: "e1c1", for: .white, in: p2)
XCTAssertEqual(wLongCastle?.result, .castle(.wQ))

let p3 = Position(fen: "4k2r/8/8/8/8/8/8/8 b kq - 0 1")!
let bShortCastle = EngineLANParser.parse(move: "e8g8", for: .black, in: p3)
XCTAssertEqual(bShortCastle?.result, .castle(.bK))

let p2 = Position(fen: "r3k3/8/8/8/8/8/8/5RK1 b q - 0 1")!
let longCastle = EngineLANParser.parse(move: "e8c8", for: .black, in: p2)
XCTAssertEqual(longCastle?.result, .castle(.bQ))
let p4 = Position(fen: "r3k3/8/8/8/8/8/8/8 b kq - 0 1")!
let bLongCastle = EngineLANParser.parse(move: "e8c8", for: .black, in: p4)
XCTAssertEqual(bLongCastle?.result, .castle(.bQ))
}

func testPromotion() {
Expand Down
36 changes: 12 additions & 24 deletions Tests/ChessKitTests/Parsers/PGNParserTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,35 +8,15 @@ import XCTest

class PGNParserTests: XCTestCase {

private let pgn =
"""
[Event "F/S Return Match"]
[Site "Belgrade, Serbia JUG"]
[Date "1992.11.04"]
[Round "29"]
[White "Fischer, Robert J."]
[Black "Spassky, Boris V."]
[Result "1/2-1/2"]
1. e4 $4 e5 2. Nf3 Nc6 3. Bb5 a6 {This opening is called the Ruy Lopez.}
4. Ba4 Nf6 5. O-O Be7 6. Re1 b5 7. Bb3 d6 8. c3 O-O 9. h3 Nb8 10. d4 Nbd7
11. c4 c6 12. cxb5 axb5 13. Nc3 Bb7 14. Bg5 b4 15. Nb1 h6 16. Bh4 c5 17. dxe5
Nxe4 18. Bxe7 Qxe7 19. exd6 Qf6 20. Nbd2 Nxd6 21. Nc4 Nxc4 22. Bxc4 Nb6
23. Ne5 Rae8 24. Bxf7+ Rxf7 25. Nxf7 Rxe1+ 26. Qxe1 Kxf7 27. Qe3 Qg5 28. Qxg5
hxg5 29. b3 Ke6 30. a3 Kd6 31. axb4 cxb4 32. Ra5 Nd5 33. f3 Bc8 34. Kf2 Bf5
35. Ra7 g6 36. Ra6+ Kc5 37. Ke1 Nf4 38. g3 Nxh3 39. Kd2 Kb5 40. Rd6 Kc5 41. Ra6
Nf2 42. g4 Bd3 43. Re6 1/2-1/2
"""

func testGameFromPGN() {
let game = PGNParser.parse(game: pgn)
let gameFromPGN = Game(pgn: pgn)
let game = PGNParser.parse(game: Game.fischerSpassky)
let gameFromPGN = Game(pgn: Game.fischerSpassky)

XCTAssertEqual(game, gameFromPGN)
}

func testTagParsing() {
let game = PGNParser.parse(game: pgn)
let game = PGNParser.parse(game: Game.fischerSpassky)

// tags
XCTAssertEqual(game?.tags.event, "F/S Return Match")
Expand All @@ -49,7 +29,7 @@ class PGNParserTests: XCTestCase {
}

func testMoveTextParsing() {
let game = PGNParser.parse(game: pgn)
let game = PGNParser.parse(game: Game.fischerSpassky)

// starting position + 85 ply
XCTAssertEqual(game?.positions.keys.count, 86)
Expand All @@ -58,10 +38,18 @@ class PGNParserTests: XCTestCase {
number: 1,
color: .white
)]?.assessment, .blunder)
XCTAssertEqual(game?.moves[.init(
number: 1,
color: .black
)]?.assessment, .brilliant)
XCTAssertEqual(game?.moves[.init(
number: 3,
color: .black
)]?.comment, "This opening is called the Ruy Lopez.")
XCTAssertEqual(game?.moves[.init(
number: 4,
color: .white
)]?.comment, "test comment")
XCTAssertEqual(game?.moves[.init(
number: 10,
color: .white
Expand Down
27 changes: 27 additions & 0 deletions Tests/ChessKitTests/Performance/PGNParserPerformanceTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
//
// PGNParserPerformanceTests.swift
// ChessKit
//

@testable import ChessKit
import XCTest

final class PGNParserPerformanceTests: XCTestCase {

func testBoardPerformance() {
measure(
metrics: [
XCTClockMetric(),
XCTCPUMetric(),
XCTMemoryMetric()
],
block: parsePGN
)
}

private func parsePGN() {
let parsedGame = PGNParser.parse(game: Game.fischerSpassky)
XCTAssertNotNil(parsedGame)
}

}
28 changes: 28 additions & 0 deletions Tests/ChessKitTests/Utilities/SampleGames.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
//
// SampleGames.swift
// ChessKit
//

@testable import struct ChessKit.Game

extension Game {
static let fischerSpassky =
"""
[Event "F/S Return Match"]
[Site "Belgrade, Serbia JUG"]
[Date "1992.11.04"]
[Round "29"]
[White "Fischer, Robert J."]
[Black "Spassky, Boris V."]
[Result "1/2-1/2"]
1. e4 $4 e5 $3 2. Nf3 Nc6 3. Bb5 a6 {This opening is called the Ruy Lopez.}
4. Ba4 {test comment} Nf6 5. O-O Be7 6. Re1 b5 7. Bb3 d6 8. c3 O-O 9. h3 Nb8 10. d4 Nbd7
11. c4 c6 12. cxb5 axb5 13. Nc3 Bb7 14. Bg5 b4 15. Nb1 h6 16. Bh4 c5 17. dxe5
Nxe4 18. Bxe7 Qxe7 19. exd6 Qf6 20. Nbd2 Nxd6 21. Nc4 Nxc4 22. Bxc4 Nb6
23. Ne5 Rae8 24. Bxf7+ Rxf7 25. Nxf7 Rxe1+ 26. Qxe1 Kxf7 27. Qe3 Qg5 28. Qxg5
hxg5 29. b3 Ke6 30. a3 Kd6 31. axb4 cxb4 32. Ra5 Nd5 33. f3 Bc8 34. Kf2 Bf5
35. Ra7 g6 36. Ra6+ Kc5 37. Ke1 Nf4 38. g3 Nxh3 39. Kd2 Kb5 40. Rd6 Kc5 41. Ra6
Nf2 42. g4 Bd3 43. Re6 1/2-1/2
"""
}

0 comments on commit 3c13f56

Please sign in to comment.