From 60f222bfcb1199e1f9c01616470b8ecd09c617db Mon Sep 17 00:00:00 2001 From: Corentin GS Date: Tue, 2 Jun 2026 19:57:33 +0200 Subject: [PATCH] feat: preserve PGN annotation structure --- CHANGELOG.md | 7 +- README.md | 25 +++++ game.go | 78 +++++++-------- game_test.go | 54 +++++++++++ move.go | 218 ++++++++++++++++++++++++++++++++++++++--- move_test.go | 85 ++++++++++++++++ pgn.go | 95 +++++------------- pgn_test.go | 268 +++++++++++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 701 insertions(+), 129 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c3dea53..c76ce5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,11 @@ # Changelog All notable changes to this project will be documented in this file. See [conventional commits](https://www.conventionalcommits.org/) for commit guidelines. +- - - +## Unreleased +#### Features +- preserve PGN annotation block structure and add `Move.CommentBlocks()` for ordered comment items. + - - - ## v2.3.2 - 2025-10-08 @@ -148,4 +153,4 @@ All notable changes to this project will be documented in this file. See [conven - - - -Changelog generated by [cocogitto](https://github.com/cocogitto/cocogitto). \ No newline at end of file +Changelog generated by [cocogitto](https://github.com/cocogitto/cocogitto). diff --git a/README.md b/README.md index 15ad129..ff69626 100644 --- a/README.md +++ b/README.md @@ -519,6 +519,31 @@ fmt.Println(game) */ ``` +#### PGN comment annotations + +Parsed PGN comments preserve comment block boundaries, command annotation order, and duplicate command annotations. Use +`Move.CommentBlocks()` when an importer needs structured access to each `{...}` block and the ordered text or command +items inside it. The returned blocks are defensive copies. + +```go +game := chess.NewGame(pgn) +move := game.Moves()[0] + +for _, block := range move.CommentBlocks() { + for _, item := range block.Items { + switch item.Kind { + case chess.CommentText: + fmt.Println(item.Text) + case chess.CommentCommand: + fmt.Printf("%s=%s\n", item.Key, item.Value) + } + } +} +``` + +The legacy helpers `Move.Comments()`, `Move.GetCommand()`, `Move.SetCommand()`, `Move.SetComment()`, and +`Move.AddComment()` remain available for callers that only need a flattened text comment or single command value. + #### Scan PGN For parsing large PGN database files use Scanner: diff --git a/game.go b/game.go index 1229ead..a6a3203 100644 --- a/game.go +++ b/game.go @@ -433,7 +433,7 @@ func (g *Game) String() string { needTrailingSpace = !writeMoves(g.rootMove, g.rootMove.Position().moveCount, g.rootMove.Position().Turn() == White, &sb, false, false, true) - } else if g.rootMove.comments != "" || len(g.rootMove.command) > 0 { + } else if g.rootMove.hasAnnotations() { writeAnnotations(g.rootMove, &sb) } } @@ -515,7 +515,7 @@ func writeMoves(node *Move, moveNum int, isWhite bool, sb *strings.Builder, } // Handle root move comments before processing children - if isRoot && (node.comments != "" || len(node.command) > 0) { + if isRoot && node.hasAnnotations() { writeAnnotations(node, sb) } @@ -588,33 +588,13 @@ func writeMoveEncoding(node *Move, currentMove *Move, subVariation bool, sb *str } } -func writeComments(move *Move, sb *strings.Builder) { - if move.comments != "" { - sb.WriteString(" {" + move.comments + "}") - } -} - -func writeCommands(move *Move, sb *strings.Builder) { - if len(move.command) > 0 { - sb.WriteString(" {") - writeSortedCommands(move, sb) - sb.WriteString("}") - } -} - -func writeSortedCommands(move *Move, sb *strings.Builder) { - keys := make([]string, 0, len(move.command)) - for key := range move.command { +func sortedCommandKeys(commands map[string]string) []string { + keys := make([]string, 0, len(commands)) + for key := range commands { keys = append(keys, key) } slices.Sort(keys) - for _, key := range keys { - sb.WriteString(" [%") - sb.WriteString(key) - sb.WriteString(" ") - sb.WriteString(move.command[key]) - sb.WriteString("]") - } + return keys } func writeAnnotations(move *Move, sb *strings.Builder) { @@ -622,28 +602,42 @@ func writeAnnotations(move *Move, sb *strings.Builder) { return } - hasComment := move.comments != "" - hasCommands := len(move.command) > 0 - - if !hasComment && !hasCommands { + move.ensureCommentBlocksFromLegacy() + if len(move.commentBlocks) > 0 { + writeCommentBlocks(move.commentBlocks, sb) return } +} - if hasComment && !hasCommands { - writeComments(move, sb) - return - } +func writeCommentBlocks(blocks []CommentBlock, sb *strings.Builder) { + for _, block := range blocks { + if len(block.Items) == 0 { + continue + } - if !hasComment { - writeCommands(move, sb) - return + sb.WriteString(" {") + for _, item := range block.Items { + switch item.Kind { + case CommentText: + sb.WriteString(item.Text) + case CommentCommand: + if needsCommandSeparator(sb) { + sb.WriteString(" ") + } + sb.WriteString("[%") + sb.WriteString(item.Key) + sb.WriteString(" ") + sb.WriteString(item.Value) + sb.WriteString("]") + } + } + sb.WriteString("}") } +} - sb.WriteString(" {") - sb.WriteString(move.comments) - writeSortedCommands(move, sb) - - sb.WriteString("}") +func needsCommandSeparator(sb *strings.Builder) bool { + s := sb.String() + return len(s) > 0 && s[len(s)-1] != ' ' } func writeVariations(node *Move, moveNum int, isWhite bool, sb *strings.Builder) bool { diff --git a/game_test.go b/game_test.go index 376edb0..40aac19 100644 --- a/game_test.go +++ b/game_test.go @@ -1602,6 +1602,60 @@ func TestRootMoveComments(t *testing.T) { }) } +func TestWriteAnnotationsLegacyBranches(t *testing.T) { + t.Run("NilMove", func(t *testing.T) { + var sb strings.Builder + writeAnnotations(nil, &sb) + if sb.String() != "" { + t.Fatalf("expected empty annotation output, got %q", sb.String()) + } + }) + + t.Run("CommentOnly", func(t *testing.T) { + move := &Move{comments: "Good move"} + move.ensureCommentBlocksFromLegacy() + move.commentBlocks = nil + + var sb strings.Builder + writeAnnotations(move, &sb) + if sb.String() != " {Good move}" { + t.Fatalf("expected comment-only annotation, got %q", sb.String()) + } + }) + + t.Run("CommandOnly", func(t *testing.T) { + move := &Move{command: map[string]string{"clk": "0:05:00"}} + move.ensureCommentBlocksFromLegacy() + move.commentBlocks = nil + + var sb strings.Builder + writeAnnotations(move, &sb) + if sb.String() != " { [%clk 0:05:00]}" { + t.Fatalf("expected command-only annotation, got %q", sb.String()) + } + }) + + t.Run("CommentAndCommand", func(t *testing.T) { + move := &Move{comments: "Good move", command: map[string]string{"clk": "0:05:00"}} + move.ensureCommentBlocksFromLegacy() + move.commentBlocks = nil + + var sb strings.Builder + writeAnnotations(move, &sb) + if sb.String() != " {Good move [%clk 0:05:00]}" { + t.Fatalf("expected merged annotation, got %q", sb.String()) + } + }) + + t.Run("EmptyStructuredBlock", func(t *testing.T) { + var sb strings.Builder + writeCommentBlocks([]CommentBlock{{}}, &sb) + if sb.String() != "" { + t.Fatalf("expected empty structured block to be skipped, got %q", sb.String()) + } + }) +} + func TestValidateSAN(t *testing.T) { tests := []struct { name string diff --git a/move.go b/move.go index 0d88e5c..7287364 100644 --- a/move.go +++ b/move.go @@ -2,6 +2,29 @@ package chess import "strings" +// CommentItemKind identifies the kind of item inside a PGN comment block. +type CommentItemKind int + +const ( + // CommentText is plain text inside a PGN comment block. + CommentText CommentItemKind = iota + // CommentCommand is a command annotation like [%clk 0:05:00]. + CommentCommand +) + +// CommentItem is one ordered item inside a PGN comment block. +type CommentItem struct { + Kind CommentItemKind + Text string + Key string + Value string +} + +// CommentBlock represents one PGN {...} block. +type CommentBlock struct { + Items []CommentItem +} + // A MoveTag represents a notable consequence of a move. type MoveTag uint16 @@ -23,17 +46,19 @@ const ( // A Move is the movement of a piece from one square to another. type Move struct { - parent *Move - position *Position // Position after the move - nag string - comments string - command map[string]string // Store commands as key-value pairs - children []*Move // Main line and variations - number uint - tags MoveTag - s1 Square - s2 Square - promo PieceType + parent *Move + position *Position // Position after the move + nag string + comments string + command map[string]string // Store commands as key-value pairs + commentBlocks []CommentBlock + structuredComments bool + children []*Move // Main line and variations + number uint + tags MoveTag + s1 Square + s2 Square + promo PieceType } // String returns a string useful for debugging. String doesn't return @@ -69,6 +94,16 @@ func (m *Move) AddTag(tag MoveTag) { } func (m *Move) GetCommand(key string) (string, bool) { + for i := len(m.commentBlocks) - 1; i >= 0; i-- { + block := m.commentBlocks[i] + for j := len(block.Items) - 1; j >= 0; j-- { + item := block.Items[j] + if item.Kind == CommentCommand && item.Key == key { + return item.Value, true + } + } + } + if m.command == nil { m.command = make(map[string]string) return "", false @@ -82,23 +117,88 @@ func (m *Move) SetCommand(key, value string) { m.command = make(map[string]string) } m.command[key] = value + + if !m.structuredComments { + m.rebuildLegacyCommentBlock() + return + } + + m.ensureCommentBlocksFromLegacy() + for blockIdx := len(m.commentBlocks) - 1; blockIdx >= 0; blockIdx-- { + for itemIdx := len(m.commentBlocks[blockIdx].Items) - 1; itemIdx >= 0; itemIdx-- { + item := &m.commentBlocks[blockIdx].Items[itemIdx] + if item.Kind == CommentCommand && item.Key == key { + item.Value = value + return + } + } + } + + if len(m.commentBlocks) == 0 { + m.commentBlocks = append(m.commentBlocks, CommentBlock{}) + } + last := len(m.commentBlocks) - 1 + m.commentBlocks[last].Items = append(m.commentBlocks[last].Items, CommentItem{ + Kind: CommentCommand, + Key: key, + Value: value, + }) } func (m *Move) SetComment(comment string) { + commands := m.command m.comments = comment + m.commentBlocks = nil + m.structuredComments = false + if comment != "" || len(commands) > 0 { + m.rebuildLegacyCommentBlockWithCommands(commands) + } } func (m *Move) AddComment(comment string) { - comments := strings.Builder{} - comments.WriteString(m.comments) - comments.WriteString(comment) - m.comments = comments.String() + if !m.structuredComments { + m.comments += comment + m.rebuildLegacyCommentBlock() + return + } + + m.ensureCommentBlocksFromLegacy() + if len(m.commentBlocks) == 0 { + m.commentBlocks = []CommentBlock{{Items: []CommentItem{{Kind: CommentText, Text: comment}}}} + m.syncLegacyAnnotationsFromBlocks() + return + } + + lastBlock := len(m.commentBlocks) - 1 + for i := len(m.commentBlocks[lastBlock].Items) - 1; i >= 0; i-- { + item := &m.commentBlocks[lastBlock].Items[i] + if item.Kind == CommentText { + item.Text += comment + m.syncLegacyAnnotationsFromBlocks() + return + } + } + m.commentBlocks[lastBlock].Items = append(m.commentBlocks[lastBlock].Items, CommentItem{Kind: CommentText, Text: comment}) + m.syncLegacyAnnotationsFromBlocks() } func (m *Move) Comments() string { + if len(m.commentBlocks) > 0 { + return flattenCommentText(m.commentBlocks) + } return m.comments } +// CommentBlocks returns a defensive copy of the move's structured PGN comment blocks. +func (m *Move) CommentBlocks() []CommentBlock { + m.ensureCommentBlocksFromLegacy() + blocks := make([]CommentBlock, len(m.commentBlocks)) + for i, block := range m.commentBlocks { + blocks[i].Items = append([]CommentItem(nil), block.Items...) + } + return blocks +} + func (m *Move) NAG() string { return m.nag } @@ -163,6 +263,8 @@ func (m *Move) Clone() *Move { ret.position = m.position.copy() ret.nag = m.nag ret.comments = m.comments + ret.commentBlocks = copyCommentBlocks(m.commentBlocks) + ret.structuredComments = m.structuredComments ret.children = make([]*Move, 0) ret.number = m.number ret.tags = m.tags @@ -178,6 +280,92 @@ func (m *Move) Clone() *Move { return ret } +func (m *Move) addCommentBlock(block CommentBlock) { + if len(block.Items) == 0 { + return + } + m.commentBlocks = append(m.commentBlocks, block) + m.structuredComments = true + m.syncLegacyAnnotationsFromBlocks() +} + +func (m *Move) hasAnnotations() bool { + return len(m.commentBlocks) > 0 || m.comments != "" || len(m.command) > 0 +} + +func (m *Move) syncLegacyAnnotationsFromBlocks() { + m.comments = flattenCommentText(m.commentBlocks) + m.command = make(map[string]string) + for _, block := range m.commentBlocks { + for _, item := range block.Items { + if item.Kind == CommentCommand { + m.command[item.Key] = item.Value + } + } + } + if len(m.command) == 0 { + m.command = nil + } +} + +func (m *Move) ensureCommentBlocksFromLegacy() { + if len(m.commentBlocks) > 0 || (m.comments == "" && len(m.command) == 0) { + return + } + + block := CommentBlock{} + if m.comments != "" { + block.Items = append(block.Items, CommentItem{Kind: CommentText, Text: m.comments}) + } + for _, key := range sortedCommandKeys(m.command) { + block.Items = append(block.Items, CommentItem{Kind: CommentCommand, Key: key, Value: m.command[key]}) + } + m.commentBlocks = []CommentBlock{block} +} + +func (m *Move) rebuildLegacyCommentBlock() { + m.rebuildLegacyCommentBlockWithCommands(m.command) +} + +func (m *Move) rebuildLegacyCommentBlockWithCommands(commands map[string]string) { + block := CommentBlock{} + if m.comments != "" { + block.Items = append(block.Items, CommentItem{Kind: CommentText, Text: m.comments}) + } + for _, key := range sortedCommandKeys(commands) { + block.Items = append(block.Items, CommentItem{Kind: CommentCommand, Key: key, Value: commands[key]}) + } + if len(block.Items) == 0 { + m.commentBlocks = nil + return + } + m.commentBlocks = []CommentBlock{block} +} + +func flattenCommentText(blocks []CommentBlock) string { + var comments []string + for _, block := range blocks { + var blockText strings.Builder + for _, item := range block.Items { + if item.Kind == CommentText { + blockText.WriteString(item.Text) + } + } + if blockText.Len() > 0 { + comments = append(comments, blockText.String()) + } + } + return strings.Join(comments, " ") +} + +func copyCommentBlocks(src []CommentBlock) []CommentBlock { + blocks := make([]CommentBlock, len(src)) + for i, block := range src { + blocks[i].Items = append([]CommentItem(nil), block.Items...) + } + return blocks +} + func (m *Move) cloneChildren(srcChildren []*Move) { if len(srcChildren) == 0 { return diff --git a/move_test.go b/move_test.go index f760064..73e5f04 100644 --- a/move_test.go +++ b/move_test.go @@ -367,6 +367,43 @@ func TestAddComment(t *testing.T) { }) } +func TestAddCommentToStructuredComments(t *testing.T) { + t.Run("AddsFirstTextBlock", func(t *testing.T) { + move := &Move{structuredComments: true} + move.AddComment("First comment.") + + blocks := move.CommentBlocks() + if len(blocks) != 1 || len(blocks[0].Items) != 1 { + t.Fatalf("expected one text block, got %#v", blocks) + } + assertCommentItem(t, blocks[0].Items[0], CommentText, "First comment.", "", "") + }) + + t.Run("AppendsToExistingTextItem", func(t *testing.T) { + move := &Move{} + move.addCommentBlock(CommentBlock{Items: []CommentItem{{Kind: CommentText, Text: "First "}}}) + move.AddComment("second") + + blocks := move.CommentBlocks() + assertCommentItem(t, blocks[0].Items[0], CommentText, "First second", "", "") + if move.Comments() != "First second" { + t.Fatalf("expected flattened comment to sync, got %q", move.Comments()) + } + }) + + t.Run("AppendsTextAfterCommandOnlyBlock", func(t *testing.T) { + move := &Move{} + move.addCommentBlock(CommentBlock{Items: []CommentItem{{Kind: CommentCommand, Key: "clk", Value: "0:05:00"}}}) + move.AddComment("after command") + + blocks := move.CommentBlocks() + if len(blocks[0].Items) != 2 { + t.Fatalf("expected command and text item, got %#v", blocks) + } + assertCommentItem(t, blocks[0].Items[1], CommentText, "after command", "", "") + }) +} + func TestNAGReturnsCorrectValue(t *testing.T) { t.Run("NAGReturnsCorrectValue", func(t *testing.T) { move := &Move{nag: "!!"} @@ -417,6 +454,54 @@ func TestGetCommand(t *testing.T) { }) } +func TestStructuredCommentCommandBranches(t *testing.T) { + t.Run("SetCommandAddsFirstStructuredBlock", func(t *testing.T) { + move := &Move{structuredComments: true} + move.SetCommand("eval", "0.25") + + blocks := move.CommentBlocks() + if len(blocks) != 1 || len(blocks[0].Items) != 1 { + t.Fatalf("expected one command block, got %#v", blocks) + } + assertCommentItem(t, blocks[0].Items[0], CommentCommand, "", "eval", "0.25") + }) + + t.Run("EmptyCommentBlockIsIgnored", func(t *testing.T) { + move := &Move{} + move.addCommentBlock(CommentBlock{}) + if move.hasAnnotations() { + t.Fatalf("empty comment block should not add annotations") + } + }) + + t.Run("LegacyCommandsBecomeCommentBlocks", func(t *testing.T) { + move := &Move{command: map[string]string{"eval": "0.25"}} + blocks := move.CommentBlocks() + if len(blocks) != 1 || len(blocks[0].Items) != 1 { + t.Fatalf("expected command-only legacy block, got %#v", blocks) + } + assertCommentItem(t, blocks[0].Items[0], CommentCommand, "", "eval", "0.25") + }) + + t.Run("EmptyLegacyAnnotationsProduceNoBlock", func(t *testing.T) { + move := &Move{} + move.rebuildLegacyCommentBlockWithCommands(nil) + if len(move.commentBlocks) != 0 { + t.Fatalf("expected no comment blocks, got %#v", move.commentBlocks) + } + }) +} + +func TestPlyNilAndMissingPosition(t *testing.T) { + var nilMove *Move + if nilMove.Ply() != 0 { + t.Fatalf("nil move should have ply 0") + } + if (&Move{}).Ply() != 0 { + t.Fatalf("move without position should have ply 0") + } +} + func BenchmarkValidMoves(b *testing.B) { pos := unsafeFEN("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1") b.ResetTimer() diff --git a/pgn.go b/pgn.go index ab64c28..2bf85d4 100644 --- a/pgn.go +++ b/pgn.go @@ -17,8 +17,6 @@ import ( "errors" "fmt" "strconv" - - "maps" ) // Parser holds the state needed during parsing. @@ -211,21 +209,12 @@ func (p *Parser) parseMoveText() error { p.currentMove.nag = tok.Value p.advance() case CommentStart: - comment, commandMap, err := p.parseComment() + block, err := p.parseComment() if err != nil { return err } if p.currentMove != nil { - if p.currentMove.command != nil { - maps.Copy(p.currentMove.command, commandMap) - } else { - p.currentMove.command = commandMap - } - if p.currentMove.comments != "" { - p.currentMove.comments += " " + comment - } else { - p.currentMove.comments = comment - } + p.currentMove.addCommentBlock(block) } default: break collectLoop @@ -233,21 +222,12 @@ func (p *Parser) parseMoveText() error { } case CommentStart: - comment, commandMap, err := p.parseComment() + block, err := p.parseComment() if err != nil { return err } if p.currentMove != nil { - if p.currentMove.command != nil { - maps.Copy(p.currentMove.command, commandMap) - } else { - p.currentMove.command = commandMap - } - if p.currentMove.comments != "" { - p.currentMove.comments += " " + comment - } else { - p.currentMove.comments = comment - } + p.currentMove.addCommentBlock(block) } case VariationStart: @@ -508,32 +488,24 @@ func (p *Parser) parseMove() (*Move, error) { return move, nil } -func (p *Parser) parseComment() (string, map[string]string, error) { +func (p *Parser) parseComment() (CommentBlock, error) { p.advance() // Consume "{" - var comment string - var commandMap map[string]string + block := CommentBlock{} for p.currentToken().Type != CommentEnd && p.position < len(p.tokens) { switch p.currentToken().Type { case CommandStart: - commands, err := p.parseCommand() + command, err := p.parseCommand() if err != nil { - return "", nil, err - } - - // merge commands into commandMap - if commandMap == nil { - commandMap = make(map[string]string) - } - for k, v := range commands { - commandMap[k] = v + return CommentBlock{}, err } + block.Items = append(block.Items, command) case COMMENT: - comment += p.currentToken().Value // Append plain comment text + block.Items = append(block.Items, CommentItem{Kind: CommentText, Text: p.currentToken().Value}) default: - return "", nil, &ParserError{ + return CommentBlock{}, &ParserError{ Message: "unexpected token in comment", Position: p.position, TokenType: p.currentToken().Type, @@ -544,19 +516,19 @@ func (p *Parser) parseComment() (string, map[string]string, error) { } if p.position >= len(p.tokens) { - return "", nil, &ParserError{ + return CommentBlock{}, &ParserError{ Message: "unterminated comment", Position: p.position, } } p.advance() // Consume "}" - return comment, commandMap, nil + return block, nil } -func (p *Parser) parseCommand() (map[string]string, error) { - command := make(map[string]string) +func (p *Parser) parseCommand() (CommentItem, error) { var key string + var value string // Consume the opening "[" p.advance() @@ -569,12 +541,11 @@ func (p *Parser) parseCommand() (map[string]string, error) { key = p.currentToken().Value case CommandParam: // The second token is treated as the value for the current key - if key != "" { - command[key] = p.currentToken().Value - key = "" // Reset key after assigning value + if key != "" && value == "" { + value = p.currentToken().Value } default: - return nil, &ParserError{ + return CommentItem{}, &ParserError{ Message: "unexpected token in command", Position: p.position, TokenType: p.currentToken().Type, @@ -585,14 +556,14 @@ func (p *Parser) parseCommand() (map[string]string, error) { } if p.position >= len(p.tokens) { - return nil, &ParserError{ + return CommentItem{}, &ParserError{ Message: "unterminated command", Position: p.position, } } // p.advance() // Consume the closing "]" - return command, nil + return CommentItem{Kind: CommentCommand, Key: key, Value: value}, nil } func (p *Parser) parseVariation(parentMoveNumber uint64, parentPly int) error { @@ -651,21 +622,12 @@ func (p *Parser) parseVariation(parentMoveNumber uint64, parentPly int) error { } case CommentStart: - comment, commandMap, err := p.parseComment() + block, err := p.parseComment() if err != nil { return err } if p.currentMove != nil { - if p.currentMove.command != nil { - maps.Copy(p.currentMove.command, commandMap) - } else { - p.currentMove.command = commandMap - } - if p.currentMove.comments != "" { - p.currentMove.comments += " " + comment - } else { - p.currentMove.comments = comment - } + p.currentMove.addCommentBlock(block) } case NAG: @@ -708,21 +670,12 @@ func (p *Parser) parseVariation(parentMoveNumber uint64, parentPly int) error { p.currentMove.nag = tok.Value p.advance() case CommentStart: - comment, commandMap, err := p.parseComment() + block, err := p.parseComment() if err != nil { return err } if p.currentMove != nil { - if p.currentMove.command != nil { - maps.Copy(p.currentMove.command, commandMap) - } else { - p.currentMove.command = commandMap - } - if p.currentMove.comments != "" { - p.currentMove.comments += " " + comment - } else { - p.currentMove.comments = comment - } + p.currentMove.addCommentBlock(block) } default: break collectVariationAnnotations diff --git a/pgn_test.go b/pgn_test.go index ae0cce4..143c86f 100644 --- a/pgn_test.go +++ b/pgn_test.go @@ -669,6 +669,274 @@ func TestRoundTripWithVariationsAndCommandAnnotations(t *testing.T) { } } +func TestPGNAnnotationFidelityRoundTrip(t *testing.T) { + pgn := withMinimalTags(`1. e4 {Good move [%clk 0:05:00]} {second [%eval 0.25]} *`) + + game := mustParseSingleGame(t, pgn) + move := game.Moves()[0] + if move.Comments() != "Good move second" { + t.Fatalf("expected flattened comments, got %q", move.Comments()) + } + if got, ok := move.GetCommand("clk"); !ok || got != "0:05:00" { + t.Fatalf("expected clk command, got %q, %v", got, ok) + } + + roundTrip := game.String() + if strings.Contains(roundTrip, "{Good move }") || strings.Contains(roundTrip, "{ [%clk") { + t.Fatalf("expected mixed comment and command to stay in one block, got %s", roundTrip) + } + + reparsed := mustParseSingleGame(t, roundTrip) + blocks := reparsed.Moves()[0].CommentBlocks() + if len(blocks) != 2 { + t.Fatalf("expected 2 comment blocks, got %#v", blocks) + } + assertCommentItem(t, blocks[0].Items[0], CommentText, "Good move", "", "") + assertCommentItem(t, blocks[0].Items[1], CommentCommand, "", "clk", "0:05:00") + assertCommentItem(t, blocks[1].Items[0], CommentText, "second", "", "") + assertCommentItem(t, blocks[1].Items[1], CommentCommand, "", "eval", "0.25") +} + +func TestPGNAnnotationFidelityPreservesOrderAndDuplicateCommands(t *testing.T) { + pgn := withMinimalTags(`1. e4 {before [%clk 0:05:00] middle [%clk 0:04:59] after} *`) + + game := mustParseSingleGame(t, pgn) + roundTrip := game.String() + reparsed := mustParseSingleGame(t, roundTrip) + move := reparsed.Moves()[0] + + if got, ok := move.GetCommand("clk"); !ok || got != "0:04:59" { + t.Fatalf("expected last duplicate clk command, got %q, %v", got, ok) + } + move.SetCommand("clk", "0:04:00") + if got, ok := move.GetCommand("clk"); !ok || got != "0:04:00" { + t.Fatalf("expected SetCommand to update last duplicate clk command, got %q, %v", got, ok) + } + + blocks := move.CommentBlocks() + if len(blocks) != 1 || len(blocks[0].Items) != 5 { + t.Fatalf("expected one block with 5 ordered items, got %#v", blocks) + } + assertCommentItem(t, blocks[0].Items[0], CommentText, "before", "", "") + assertCommentItem(t, blocks[0].Items[1], CommentCommand, "", "clk", "0:05:00") + assertCommentItem(t, blocks[0].Items[2], CommentText, "middle", "", "") + assertCommentItem(t, blocks[0].Items[3], CommentCommand, "", "clk", "0:04:00") + assertCommentItem(t, blocks[0].Items[4], CommentText, "after", "", "") +} + +func TestPGNAnnotationFidelityCommandUsesFirstParameter(t *testing.T) { + game := mustParseSingleGame(t, withMinimalTags(`1. e4 {[%command 1:45:12,Nf6,"very interesting, but wrong"]} *`)) + move := game.Moves()[0] + + if got, ok := move.GetCommand("command"); !ok || got != "1:45:12" { + t.Fatalf("expected first command parameter, got %q, %v", got, ok) + } + + blocks := move.CommentBlocks() + if len(blocks) != 1 || len(blocks[0].Items) != 1 { + t.Fatalf("expected one command item, got %#v", blocks) + } + assertCommentItem(t, blocks[0].Items[0], CommentCommand, "", "command", "1:45:12") +} + +func TestPGNAnnotationFidelityVariationsAndExpansion(t *testing.T) { + pgn := withMinimalTags(`1. e4 e5 (1... c5 {Sicilian [%eval 0.12]} 2. Nf3 {develop [%clk 0:04:58]}) 2. Nf3 *`) + + game := mustParseSingleGame(t, pgn) + roundTrip := game.String() + reparsed := mustParseSingleGame(t, roundTrip) + + e4 := reparsed.Moves()[0] + if len(e4.Children()) < 2 { + t.Fatalf("expected e4 variation, got %d children", len(e4.Children())) + } + c5 := e4.Children()[1] + blocks := c5.CommentBlocks() + if len(blocks) != 1 || len(blocks[0].Items) != 2 { + t.Fatalf("expected variation annotation block, got %#v", blocks) + } + assertCommentItem(t, blocks[0].Items[0], CommentText, "Sicilian", "", "") + assertCommentItem(t, blocks[0].Items[1], CommentCommand, "", "eval", "0.12") + + scanner := NewScanner(strings.NewReader(roundTrip), WithExpandVariations()) + for scanner.HasNext() { + if _, err := scanner.ParseNext(); err != nil { + t.Fatalf("expanded annotated variation should parse: %v", err) + } + } +} + +func TestPGNAnnotationFidelityLegacyAPIsAndDefensiveCopies(t *testing.T) { + game := NewGame() + if err := game.PushMove("e4", nil); err != nil { + t.Fatal(err) + } + move := game.Moves()[0] + move.SetComment("Good move") + move.SetCommand("clk", "0:05:00") + move.SetCommand("clk", "0:04:59") + move.AddComment("!") + + if move.Comments() != "Good move!" { + t.Fatalf("expected legacy comments, got %q", move.Comments()) + } + if got, ok := move.GetCommand("clk"); !ok || got != "0:04:59" { + t.Fatalf("expected updated clk command, got %q, %v", got, ok) + } + if !strings.Contains(game.String(), "{Good move! [%clk 0:04:59]}") { + t.Fatalf("expected legacy comment serialization, got %s", game.String()) + } + + parsed := mustParseSingleGame(t, withMinimalTags(`1. e4 {Good [%clk 0:05:00]} *`)) + parsedMove := parsed.Moves()[0] + blocks := parsedMove.CommentBlocks() + blocks[0].Items[0].Text = "mutated" + blocks[0].Items = append(blocks[0].Items, CommentItem{Kind: CommentCommand, Key: "eval", Value: "9"}) + + blocks = parsedMove.CommentBlocks() + assertCommentItem(t, blocks[0].Items[0], CommentText, "Good", "", "") + if strings.Contains(parsed.String(), "mutated") || strings.Contains(parsed.String(), "[%eval 9]") { + t.Fatalf("mutating CommentBlocks result changed move state: %s", parsed.String()) + } +} + +func mustParseSingleGame(t *testing.T, pgn string) *Game { + t.Helper() + scanner := NewScanner(strings.NewReader(pgn)) + game, err := scanner.ParseNext() + if err != nil { + t.Fatalf("failed to parse pgn: %v", err) + } + if game == nil { + t.Fatal("expected game") + } + return game +} + +func withMinimalTags(moveText string) string { + return `[Event "Test"] +[Site "Internet"] +[Date "2026.06.02"] +[Round "1"] +[White "White"] +[Black "Black"] +[Result "*"] + +` + moveText +} + +func assertCommentItem(t *testing.T, item CommentItem, kind CommentItemKind, text, key, value string) { + t.Helper() + if item.Kind != kind || item.Text != text || item.Key != key || item.Value != value { + t.Fatalf("unexpected comment item: got %#v", item) + } +} + +func TestParserAnnotationErrors(t *testing.T) { + t.Run("UnexpectedTokenInComment", func(t *testing.T) { + parser := NewParser([]Token{ + {Type: CommentStart, Value: "{"}, + {Type: MoveNumber, Value: "1"}, + {Type: CommentEnd, Value: "}"}, + }) + _, err := parser.parseComment() + if err == nil { + t.Fatal("expected parseComment error") + } + }) + + t.Run("UnterminatedComment", func(t *testing.T) { + parser := NewParser([]Token{ + {Type: CommentStart, Value: "{"}, + {Type: COMMENT, Value: "missing close"}, + }) + _, err := parser.parseComment() + if err == nil { + t.Fatal("expected unterminated comment error") + } + }) + + t.Run("CommandErrorPropagatesFromComment", func(t *testing.T) { + parser := NewParser([]Token{ + {Type: CommentStart, Value: "{"}, + {Type: CommandStart, Value: "[%"}, + {Type: MoveNumber, Value: "1"}, + {Type: CommandEnd, Value: "]"}, + {Type: CommentEnd, Value: "}"}, + }) + _, err := parser.parseComment() + if err == nil { + t.Fatal("expected command parse error") + } + }) + + t.Run("UnexpectedTokenInCommand", func(t *testing.T) { + parser := NewParser([]Token{ + {Type: CommandStart, Value: "[%"}, + {Type: MoveNumber, Value: "1"}, + {Type: CommandEnd, Value: "]"}, + }) + _, err := parser.parseCommand() + if err == nil { + t.Fatal("expected parseCommand error") + } + }) + + t.Run("UnterminatedCommand", func(t *testing.T) { + parser := NewParser([]Token{ + {Type: CommandStart, Value: "[%"}, + {Type: CommandName, Value: "clk"}, + }) + _, err := parser.parseCommand() + if err == nil { + t.Fatal("expected unterminated command error") + } + }) +} + +func TestParserVariationErrors(t *testing.T) { + t.Run("UnterminatedVariation", func(t *testing.T) { + parser := NewParser([]Token{{Type: VariationStart, Value: "("}}) + if err := parser.parseVariation(1, 1); err == nil { + t.Fatal("expected unterminated variation error") + } + }) + + t.Run("MoveColorMismatch", func(t *testing.T) { + parser := NewParser([]Token{ + {Type: VariationStart, Value: "("}, + {Type: MoveNumber, Value: "1"}, + {Type: DOT, Value: "."}, + {Type: ELLIPSIS, Value: "..."}, + {Type: SQUARE, Value: "e5"}, + {Type: VariationEnd, Value: ")"}, + }) + if err := parser.parseVariation(1, 1); err == nil { + t.Fatal("expected move color mismatch error") + } + }) + + t.Run("CommentErrorInsideVariation", func(t *testing.T) { + parser := NewParser([]Token{ + {Type: VariationStart, Value: "("}, + {Type: CommentStart, Value: "{"}, + {Type: MoveNumber, Value: "1"}, + {Type: VariationEnd, Value: ")"}, + }) + if err := parser.parseVariation(1, 1); err == nil { + t.Fatal("expected variation comment error") + } + }) +} + +func TestParseResultDraw(t *testing.T) { + parser := NewParser([]Token{{Type: RESULT, Value: "1/2-1/2"}}) + parser.parseResult() + if parser.game.Outcome() != Draw { + t.Fatalf("expected draw outcome, got %s", parser.game.Outcome()) + } +} + func TestVariationMoveNumbers(t *testing.T) { pgn := `[Event "VariationTest"] [Site "Internet"]