Merge branch 'main' of https://github.com/vitorpamplona/amethyst into upstream-main
# Conflicts: # gradle/libs.versions.toml # quartz/build.gradle.kts # quartz/src/androidHostTest/kotlin/com/vitorpamplona/quartz/TestResourceLoader.android.kt # quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/GZip.ios.kt # quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/TestResourceLoader.kt
This commit is contained in:
+13
-11
@@ -56,18 +56,20 @@ class HintIndexerTest {
|
||||
)
|
||||
}
|
||||
|
||||
val relays =
|
||||
TestResourceLoader()
|
||||
.loadString("relayDB.txt")
|
||||
.split('\n')
|
||||
.mapNotNull {
|
||||
val relay = RelayUrlNormalizer.normalizeOrNull(it)
|
||||
if (relay == null || relay.isLocalHost()) {
|
||||
null
|
||||
} else {
|
||||
relay
|
||||
val relays by
|
||||
lazy {
|
||||
TestResourceLoader()
|
||||
.loadString("relayDB.txt")
|
||||
.split('\n')
|
||||
.mapNotNull {
|
||||
val relay = RelayUrlNormalizer.normalizeOrNull(it)
|
||||
if (relay == null || relay.isLocalHost()) {
|
||||
null
|
||||
} else {
|
||||
relay
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val indexer by lazy {
|
||||
val result = HintIndexer()
|
||||
|
||||
+288
@@ -0,0 +1,288 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip64Chess
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Tests for ChessGameEvent (NIP-64 Kind 64 event)
|
||||
*
|
||||
* Verifies:
|
||||
* - Event kind is 64
|
||||
* - PGN content storage
|
||||
* - Alt text support (NIP-31)
|
||||
* - Event structure compliance
|
||||
*/
|
||||
class ChessGameEventTest {
|
||||
private val samplePGN =
|
||||
"""
|
||||
[Event "Test Game"]
|
||||
[Site "Internet"]
|
||||
[Date "2024.12.28"]
|
||||
[Round "1"]
|
||||
[White "Alice"]
|
||||
[Black "Bob"]
|
||||
[Result "1-0"]
|
||||
|
||||
1. e4 e5 2. Qh5 Nc6 3. Bc4 Nf6 4. Qxf7# 1-0
|
||||
""".trimIndent()
|
||||
|
||||
@Test
|
||||
fun `verify event kind is 64`() {
|
||||
assertEquals(64, ChessGameEvent.KIND, "Chess game event should be kind 64")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pgn content is accessible`() {
|
||||
// Create a mock event manually for testing
|
||||
val testEvent =
|
||||
ChessGameEvent(
|
||||
id = "test_id",
|
||||
pubKey = "test_pubkey",
|
||||
createdAt = 1000L,
|
||||
tags = arrayOf(arrayOf("alt", "Chess Game")),
|
||||
content = samplePGN,
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
assertEquals(samplePGN, testEvent.pgn(), "PGN content should be accessible via pgn()")
|
||||
assertEquals(samplePGN, testEvent.content, "PGN should be in content field")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `alt text is accessible when present`() {
|
||||
val customAltText = "Scholar's Mate Example"
|
||||
val testEvent =
|
||||
ChessGameEvent(
|
||||
id = "test_id",
|
||||
pubKey = "test_pubkey",
|
||||
createdAt = 1000L,
|
||||
tags = arrayOf(arrayOf("alt", customAltText)),
|
||||
content = samplePGN,
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
assertEquals(customAltText, testEvent.altText(), "Alt text should be extractable from tags")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `alt text returns null when not present`() {
|
||||
val testEvent =
|
||||
ChessGameEvent(
|
||||
id = "test_id",
|
||||
pubKey = "test_pubkey",
|
||||
createdAt = 1000L,
|
||||
tags = emptyArray(),
|
||||
content = samplePGN,
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
assertEquals(null, testEvent.altText(), "Should return null when no alt tag present")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `event can store complete game with metadata`() {
|
||||
val testEvent =
|
||||
ChessGameEvent(
|
||||
id = "test_id",
|
||||
pubKey = "test_pubkey",
|
||||
createdAt = 1000L,
|
||||
tags = arrayOf(arrayOf("alt", "Chess Game")),
|
||||
content = samplePGN,
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
// Parse the PGN to verify it's valid
|
||||
val gameResult = PGNParser.parse(testEvent.pgn())
|
||||
assertTrue(gameResult.isSuccess, "Event should contain valid PGN")
|
||||
|
||||
val game = gameResult.getOrThrow()
|
||||
assertEquals("Test Game", game.event)
|
||||
assertEquals("Alice", game.white)
|
||||
assertEquals("Bob", game.black)
|
||||
assertEquals(GameResult.WHITE_WINS, game.result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `event can store minimal PGN`() {
|
||||
val minimalPGN = "1. e4 *"
|
||||
val testEvent =
|
||||
ChessGameEvent(
|
||||
id = "test_id",
|
||||
pubKey = "test_pubkey",
|
||||
createdAt = 1000L,
|
||||
tags = arrayOf(arrayOf("alt", "Chess Game")),
|
||||
content = minimalPGN,
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
val gameResult = PGNParser.parse(testEvent.pgn())
|
||||
assertTrue(gameResult.isSuccess, "Should handle minimal PGN")
|
||||
|
||||
val game = gameResult.getOrThrow()
|
||||
assertEquals(1, game.moves.size)
|
||||
assertEquals(GameResult.IN_PROGRESS, game.result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `event can store game in progress`() {
|
||||
val inProgressPGN =
|
||||
"""
|
||||
[Event "Live Game"]
|
||||
[Result "*"]
|
||||
|
||||
1. e4 e5 2. Nf3 Nc6 3. Bb5 *
|
||||
""".trimIndent()
|
||||
|
||||
val testEvent =
|
||||
ChessGameEvent(
|
||||
id = "test_id",
|
||||
pubKey = "test_pubkey",
|
||||
createdAt = 1000L,
|
||||
tags = arrayOf(arrayOf("alt", "Live Chess Game")),
|
||||
content = inProgressPGN,
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
val gameResult = PGNParser.parse(testEvent.pgn())
|
||||
assertTrue(gameResult.isSuccess)
|
||||
|
||||
val game = gameResult.getOrThrow()
|
||||
assertEquals(GameResult.IN_PROGRESS, game.result)
|
||||
assertEquals("*", game.metadata["Result"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `event preserves PGN formatting`() {
|
||||
val formattedPGN =
|
||||
"""
|
||||
[Event "Formatted Game"]
|
||||
[White "Player 1"]
|
||||
[Black "Player 2"]
|
||||
|
||||
1. e4 e5
|
||||
2. Nf3 Nc6
|
||||
3. Bb5 a6
|
||||
*
|
||||
""".trimIndent()
|
||||
|
||||
val testEvent =
|
||||
ChessGameEvent(
|
||||
id = "test_id",
|
||||
pubKey = "test_pubkey",
|
||||
createdAt = 1000L,
|
||||
tags = arrayOf(arrayOf("alt", "Chess Game")),
|
||||
content = formattedPGN,
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
// Content should be preserved exactly as provided
|
||||
assertEquals(formattedPGN, testEvent.pgn())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `default alt text is Chess Game`() {
|
||||
assertEquals("Chess Game", ChessGameEvent.ALT_DESCRIPTION)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `event inherits from Event base class`() {
|
||||
val testEvent =
|
||||
ChessGameEvent(
|
||||
id = "test_id",
|
||||
pubKey = "test_pubkey",
|
||||
createdAt = 1000L,
|
||||
tags = emptyArray(),
|
||||
content = "1. e4 *",
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
// Verify base Event properties
|
||||
assertEquals("test_id", testEvent.id)
|
||||
assertEquals("test_pubkey", testEvent.pubKey)
|
||||
assertEquals(1000L, testEvent.createdAt)
|
||||
assertEquals(64, testEvent.kind)
|
||||
assertEquals("test_sig", testEvent.sig)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `event can contain long tournament game`() {
|
||||
val longPGN =
|
||||
"""
|
||||
[Event "Tournament Game"]
|
||||
[Site "Online"]
|
||||
[Date "2024.12.28"]
|
||||
[Round "5"]
|
||||
[White "GM Player"]
|
||||
[Black "IM Player"]
|
||||
[Result "1/2-1/2"]
|
||||
|
||||
1. d4 Nf6 2. c4 g6 3. Nc3 Bg7 4. e4 d6 5. Nf3 O-O 6. Be2 e5
|
||||
7. O-O Nc6 8. d5 Ne7 9. Ne1 Nd7 10. Nd3 f5 11. Bd2 Nf6 12. f3 f4
|
||||
13. Rc1 g5 14. Nb5 Ng6 15. c5 Rf7 16. Qa4 h5 17. Rfe1 Bf8
|
||||
18. cxd6 cxd6 19. Rc6 Bd7 20. Rec1 Bxc6 21. Rxc6 Qd7 22. Rc1 Rc8
|
||||
23. Rxc8 Qxc8 24. Qa6 Qc2 25. Qxb7 Qxb2 26. Qxa7 Qxa2 27. Qb7 Qa1+
|
||||
28. Kf2 Qb2 29. Qa7 Ra7 30. Qa4 Qa2 31. Qa8 Qb2 32. Qa4 Qa2 1/2-1/2
|
||||
""".trimIndent()
|
||||
|
||||
val testEvent =
|
||||
ChessGameEvent(
|
||||
id = "test_id",
|
||||
pubKey = "test_pubkey",
|
||||
createdAt = 1000L,
|
||||
tags = arrayOf(arrayOf("alt", "Long Tournament Game")),
|
||||
content = longPGN,
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
val gameResult = PGNParser.parse(testEvent.pgn())
|
||||
assertTrue(gameResult.isSuccess)
|
||||
|
||||
val game = gameResult.getOrThrow()
|
||||
assertTrue(game.moves.size > 50, "Should handle long games")
|
||||
assertEquals(GameResult.DRAW, game.result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `verify tags array structure`() {
|
||||
val testEvent =
|
||||
ChessGameEvent(
|
||||
id = "test_id",
|
||||
pubKey = "test_pubkey",
|
||||
createdAt = 1000L,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("alt", "Test Alt Text"),
|
||||
arrayOf("t", "chess"),
|
||||
arrayOf("t", "game"),
|
||||
),
|
||||
content = "1. e4 *",
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
// Verify tags structure
|
||||
assertTrue(testEvent.tags.size >= 1)
|
||||
assertEquals("alt", testEvent.tags[0][0])
|
||||
assertEquals("Test Alt Text", testEvent.tags[0][1])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip64Chess
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Tests for JesterEvent (Jester Protocol kind 30 events)
|
||||
*
|
||||
* Verifies:
|
||||
* - Event kind is 30
|
||||
* - JSON content parsing (version, kind, fen, move, history)
|
||||
* - e-tag structure for event linking
|
||||
* - p-tag for opponent tagging
|
||||
* - Start vs Move event detection
|
||||
* - Compatibility with jesterui protocol
|
||||
*
|
||||
* Reference: https://github.com/jesterui/jesterui/blob/devel/FLOW.md
|
||||
*/
|
||||
class JesterEventTest {
|
||||
private val testPubkey = "abc123def456"
|
||||
private val opponentPubkey = "opponent789xyz"
|
||||
private val startEventId = "start-event-id-001"
|
||||
|
||||
// ==========================================================================
|
||||
// PROTOCOL CONSTANTS
|
||||
// ==========================================================================
|
||||
|
||||
@Test
|
||||
fun `verify event kind is 30`() {
|
||||
assertEquals(30, JesterProtocol.KIND, "Jester protocol uses kind 30")
|
||||
assertEquals(30, JesterEvent.KIND, "JesterEvent.KIND should be 30")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `verify start position hash constant`() {
|
||||
assertEquals(
|
||||
"b1791d7fc9ae3d38966568c257ffb3a02cbf8394cdb4805bc70f64fc3c0b6879",
|
||||
JesterProtocol.START_POSITION_HASH,
|
||||
"Should match jesterui's START_POSITION_HASH",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `verify starting FEN constant`() {
|
||||
assertEquals(
|
||||
"rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1",
|
||||
JesterProtocol.FEN_START,
|
||||
"Standard chess starting position",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `verify content kind constants`() {
|
||||
assertEquals(0, JesterProtocol.CONTENT_KIND_START, "Start event kind should be 0")
|
||||
assertEquals(1, JesterProtocol.CONTENT_KIND_MOVE, "Move event kind should be 1")
|
||||
assertEquals(2, JesterProtocol.CONTENT_KIND_CHAT, "Chat event kind should be 2")
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// START EVENT TESTS
|
||||
// ==========================================================================
|
||||
|
||||
@Test
|
||||
fun `parse start event - open challenge`() {
|
||||
val content = """{"version":"0","kind":0,"fen":"${JesterProtocol.FEN_START}","history":[],"nonce":"abc12345","playerColor":"white"}"""
|
||||
val event =
|
||||
JesterEvent(
|
||||
id = startEventId,
|
||||
pubKey = testPubkey,
|
||||
createdAt = 1000L,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("e", JesterProtocol.START_POSITION_HASH),
|
||||
),
|
||||
content = content,
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
assertEquals(30, event.kind)
|
||||
assertTrue(event.isStartEvent(), "Should be detected as start event")
|
||||
assertFalse(event.isMoveEvent(), "Should not be a move event")
|
||||
assertEquals(0, event.contentKind())
|
||||
assertEquals(JesterProtocol.FEN_START, event.fen())
|
||||
assertEquals(Color.WHITE, event.playerColor())
|
||||
assertEquals("abc12345", event.nonce())
|
||||
assertTrue(event.history().isEmpty(), "Start event has no history")
|
||||
assertNull(event.opponentPubkey(), "Open challenge has no opponent")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse start event - private challenge`() {
|
||||
val content = """{"version":"0","kind":0,"fen":"${JesterProtocol.FEN_START}","history":[],"nonce":"xyz98765","playerColor":"black"}"""
|
||||
val event =
|
||||
JesterEvent(
|
||||
id = startEventId,
|
||||
pubKey = testPubkey,
|
||||
createdAt = 1000L,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("e", JesterProtocol.START_POSITION_HASH),
|
||||
arrayOf("p", opponentPubkey),
|
||||
),
|
||||
content = content,
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
assertTrue(event.isStartEvent())
|
||||
assertEquals(Color.BLACK, event.playerColor())
|
||||
assertEquals(opponentPubkey, event.opponentPubkey(), "Private challenge should have opponent")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `start event e-tag references START_POSITION_HASH`() {
|
||||
val content = """{"version":"0","kind":0,"fen":"${JesterProtocol.FEN_START}","history":[]}"""
|
||||
val event =
|
||||
JesterEvent(
|
||||
id = startEventId,
|
||||
pubKey = testPubkey,
|
||||
createdAt = 1000L,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("e", JesterProtocol.START_POSITION_HASH),
|
||||
),
|
||||
content = content,
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
val eTags = event.eTags()
|
||||
assertEquals(1, eTags.size)
|
||||
assertEquals(JesterProtocol.START_POSITION_HASH, eTags[0], "Start event should reference START_POSITION_HASH")
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// MOVE EVENT TESTS
|
||||
// ==========================================================================
|
||||
|
||||
@Test
|
||||
fun `parse move event - first move e4`() {
|
||||
val content = """{"version":"0","kind":1,"fen":"rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1","move":"e4","history":["e4"]}"""
|
||||
val event =
|
||||
JesterEvent(
|
||||
id = "move-001",
|
||||
pubKey = testPubkey,
|
||||
createdAt = 2000L,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("e", startEventId),
|
||||
arrayOf("e", startEventId), // For first move, head is also start
|
||||
arrayOf("p", opponentPubkey),
|
||||
),
|
||||
content = content,
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
assertFalse(event.isStartEvent(), "Should not be a start event")
|
||||
assertTrue(event.isMoveEvent(), "Should be detected as move event")
|
||||
assertEquals(1, event.contentKind())
|
||||
assertEquals("e4", event.move())
|
||||
assertEquals(listOf("e4"), event.history())
|
||||
assertEquals("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", event.fen())
|
||||
assertEquals(opponentPubkey, event.opponentPubkey())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse move event - multiple moves in history`() {
|
||||
val content = """{"version":"0","kind":1,"fen":"rnbqkbnr/pppp1ppp/8/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R b KQkq - 1 2","move":"Nf3","history":["e4","e5","Nf3"]}"""
|
||||
val event =
|
||||
JesterEvent(
|
||||
id = "move-003",
|
||||
pubKey = testPubkey,
|
||||
createdAt = 4000L,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("e", startEventId),
|
||||
arrayOf("e", "move-002"), // Previous move
|
||||
arrayOf("p", opponentPubkey),
|
||||
),
|
||||
content = content,
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
assertTrue(event.isMoveEvent())
|
||||
assertEquals("Nf3", event.move())
|
||||
assertEquals(listOf("e4", "e5", "Nf3"), event.history())
|
||||
assertEquals(3, event.history().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `move event e-tags structure - startEventId and headEventId`() {
|
||||
val content = """{"version":"0","kind":1,"fen":"test","move":"e4","history":["e4"]}"""
|
||||
val headEventId = "move-002"
|
||||
val event =
|
||||
JesterEvent(
|
||||
id = "move-003",
|
||||
pubKey = testPubkey,
|
||||
createdAt = 3000L,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("e", startEventId),
|
||||
arrayOf("e", headEventId),
|
||||
arrayOf("p", opponentPubkey),
|
||||
),
|
||||
content = content,
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
assertEquals(startEventId, event.startEventId(), "First e-tag should be startEventId")
|
||||
assertEquals(headEventId, event.headEventId(), "Second e-tag should be headEventId")
|
||||
|
||||
val eTags = event.eTags()
|
||||
assertEquals(2, eTags.size)
|
||||
assertEquals(startEventId, eTags[0])
|
||||
assertEquals(headEventId, eTags[1])
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// GAME END EVENT TESTS
|
||||
// ==========================================================================
|
||||
|
||||
@Test
|
||||
fun `parse game end event - checkmate`() {
|
||||
val content = """{"version":"0","kind":1,"fen":"r1bqkb1r/pppp1Qpp/2n2n2/4p3/2B1P3/8/PPPP1PPP/RNB1K1NR b KQkq - 0 4","move":"Qxf7#","history":["e4","e5","Qh5","Nc6","Bc4","Nf6","Qxf7#"],"result":"1-0","termination":"checkmate"}"""
|
||||
val event =
|
||||
JesterEvent(
|
||||
id = "move-007",
|
||||
pubKey = testPubkey,
|
||||
createdAt = 8000L,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("e", startEventId),
|
||||
arrayOf("e", "move-006"),
|
||||
arrayOf("p", opponentPubkey),
|
||||
),
|
||||
content = content,
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
assertTrue(event.isMoveEvent(), "End event is still a move event")
|
||||
assertEquals("1-0", event.result(), "Should have result")
|
||||
assertEquals("checkmate", event.termination(), "Should have termination reason")
|
||||
assertEquals(7, event.history().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse game end event - resignation`() {
|
||||
val content = """{"version":"0","kind":1,"fen":"test","move":"e5","history":["e4","e5"],"result":"0-1","termination":"resignation"}"""
|
||||
val event =
|
||||
JesterEvent(
|
||||
id = "move-002",
|
||||
pubKey = opponentPubkey,
|
||||
createdAt = 3000L,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("e", startEventId),
|
||||
arrayOf("e", "move-001"),
|
||||
arrayOf("p", testPubkey),
|
||||
),
|
||||
content = content,
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
assertEquals("0-1", event.result(), "Black wins")
|
||||
assertEquals("resignation", event.termination())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parse game end event - draw`() {
|
||||
val content = """{"version":"0","kind":1,"fen":"test","move":"Kf1","history":["e4","e5","Kf1"],"result":"1/2-1/2","termination":"draw_agreement"}"""
|
||||
val event =
|
||||
JesterEvent(
|
||||
id = "move-003",
|
||||
pubKey = testPubkey,
|
||||
createdAt = 4000L,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("e", startEventId),
|
||||
arrayOf("e", "move-002"),
|
||||
arrayOf("p", opponentPubkey),
|
||||
),
|
||||
content = content,
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
assertEquals("1/2-1/2", event.result(), "Draw")
|
||||
assertEquals("draw_agreement", event.termination())
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// EDGE CASES AND ERROR HANDLING
|
||||
// ==========================================================================
|
||||
|
||||
@Test
|
||||
fun `handle malformed JSON content gracefully`() {
|
||||
val event =
|
||||
JesterEvent(
|
||||
id = "test",
|
||||
pubKey = testPubkey,
|
||||
createdAt = 1000L,
|
||||
tags = emptyArray(),
|
||||
content = "invalid json {{{}",
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
assertNull(event.contentKind(), "Should return null for invalid JSON")
|
||||
assertFalse(event.isStartEvent(), "Should not crash on invalid content")
|
||||
assertFalse(event.isMoveEvent(), "Should not crash on invalid content")
|
||||
assertNull(event.fen())
|
||||
assertNull(event.move())
|
||||
assertTrue(event.history().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handle empty content`() {
|
||||
val event =
|
||||
JesterEvent(
|
||||
id = "test",
|
||||
pubKey = testPubkey,
|
||||
createdAt = 1000L,
|
||||
tags = emptyArray(),
|
||||
content = "",
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
assertNull(event.contentKind())
|
||||
assertFalse(event.isStartEvent())
|
||||
assertFalse(event.isMoveEvent())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handle missing optional fields`() {
|
||||
// Minimal valid content with only required fields
|
||||
val content = """{"kind":1}"""
|
||||
val event =
|
||||
JesterEvent(
|
||||
id = "test",
|
||||
pubKey = testPubkey,
|
||||
createdAt = 1000L,
|
||||
tags = emptyArray(),
|
||||
content = content,
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
assertEquals(1, event.contentKind())
|
||||
assertNull(event.move())
|
||||
assertTrue(event.history().isEmpty())
|
||||
assertNull(event.result())
|
||||
assertNull(event.termination())
|
||||
assertNull(event.playerColor())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handle event with no e-tags`() {
|
||||
val content = """{"version":"0","kind":0}"""
|
||||
val event =
|
||||
JesterEvent(
|
||||
id = "test",
|
||||
pubKey = testPubkey,
|
||||
createdAt = 1000L,
|
||||
tags = emptyArray(),
|
||||
content = content,
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
assertNull(event.startEventId(), "No e-tags means no startEventId")
|
||||
assertNull(event.headEventId(), "No e-tags means no headEventId")
|
||||
assertTrue(event.eTags().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handle event with only one e-tag`() {
|
||||
val content = """{"version":"0","kind":1}"""
|
||||
val event =
|
||||
JesterEvent(
|
||||
id = "test",
|
||||
pubKey = testPubkey,
|
||||
createdAt = 1000L,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("e", startEventId),
|
||||
),
|
||||
content = content,
|
||||
sig = "test_sig",
|
||||
)
|
||||
|
||||
assertEquals(startEventId, event.startEventId())
|
||||
assertNull(event.headEventId(), "Only one e-tag means no headEventId")
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// JESTER GAME EVENTS CONTAINER TESTS
|
||||
// ==========================================================================
|
||||
|
||||
@Test
|
||||
fun `JesterGameEvents - empty returns correct values`() {
|
||||
val events = JesterGameEvents.empty()
|
||||
|
||||
assertNull(events.startEvent)
|
||||
assertTrue(events.moves.isEmpty())
|
||||
assertNull(events.latestMove())
|
||||
assertEquals(JesterProtocol.FEN_START, events.currentFen())
|
||||
assertTrue(events.fullHistory().isEmpty())
|
||||
assertFalse(events.isEnded())
|
||||
assertNull(events.result())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `JesterGameEvents - latestMove returns move with longest history`() {
|
||||
val move1 = createTestMoveEvent("move-001", listOf("e4"))
|
||||
val move2 = createTestMoveEvent("move-002", listOf("e4", "e5"))
|
||||
val move3 = createTestMoveEvent("move-003", listOf("e4", "e5", "Nf3"))
|
||||
|
||||
// Provide moves in random order
|
||||
val events =
|
||||
JesterGameEvents(
|
||||
startEvent = null,
|
||||
moves = listOf(move2, move3, move1),
|
||||
)
|
||||
|
||||
val latest = events.latestMove()
|
||||
assertNotNull(latest)
|
||||
assertEquals("move-003", latest.id, "Should return move with longest history")
|
||||
assertEquals(3, latest.history().size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `JesterGameEvents - currentFen returns FEN from latest move`() {
|
||||
val move1 = createTestMoveEvent("move-001", listOf("e4"), fen = "fen-after-e4")
|
||||
val move2 = createTestMoveEvent("move-002", listOf("e4", "e5"), fen = "fen-after-e5")
|
||||
|
||||
val events =
|
||||
JesterGameEvents(
|
||||
startEvent = null,
|
||||
moves = listOf(move1, move2),
|
||||
)
|
||||
|
||||
assertEquals("fen-after-e5", events.currentFen())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `JesterGameEvents - fullHistory returns history from latest move`() {
|
||||
val move1 = createTestMoveEvent("move-001", listOf("e4"))
|
||||
val move2 = createTestMoveEvent("move-002", listOf("e4", "e5"))
|
||||
|
||||
val events =
|
||||
JesterGameEvents(
|
||||
startEvent = null,
|
||||
moves = listOf(move1, move2),
|
||||
)
|
||||
|
||||
assertEquals(listOf("e4", "e5"), events.fullHistory())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `JesterGameEvents - isEnded detects result in latest move`() {
|
||||
val normalMove = createTestMoveEvent("move-001", listOf("e4"))
|
||||
val endMove = createTestMoveEventWithResult("move-002", listOf("e4", "Qxf7#"), result = "1-0")
|
||||
|
||||
val ongoingGame = JesterGameEvents(startEvent = null, moves = listOf(normalMove))
|
||||
val finishedGame = JesterGameEvents(startEvent = null, moves = listOf(normalMove, endMove))
|
||||
|
||||
assertFalse(ongoingGame.isEnded())
|
||||
assertTrue(finishedGame.isEnded())
|
||||
assertEquals("1-0", finishedGame.result())
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// EXTENSION FUNCTION TESTS
|
||||
// ==========================================================================
|
||||
|
||||
@Test
|
||||
fun `isJesterEvent extension detects kind 30`() {
|
||||
val jesterEvent =
|
||||
JesterEvent(
|
||||
id = "test",
|
||||
pubKey = testPubkey,
|
||||
createdAt = 1000L,
|
||||
tags = emptyArray(),
|
||||
content = "{}",
|
||||
sig = "sig",
|
||||
)
|
||||
|
||||
assertTrue(jesterEvent.isJesterEvent())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toJesterEvent converts valid Event`() {
|
||||
val event =
|
||||
JesterEvent(
|
||||
id = "test",
|
||||
pubKey = testPubkey,
|
||||
createdAt = 1000L,
|
||||
tags = emptyArray(),
|
||||
content = """{"kind":0}""",
|
||||
sig = "sig",
|
||||
)
|
||||
|
||||
val jesterEvent = event.toJesterEvent()
|
||||
assertNotNull(jesterEvent)
|
||||
assertEquals("test", jesterEvent.id)
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// HELPER FUNCTIONS
|
||||
// ==========================================================================
|
||||
|
||||
private fun createTestMoveEvent(
|
||||
id: String,
|
||||
history: List<String>,
|
||||
fen: String = "test-fen",
|
||||
): JesterEvent {
|
||||
val historyJson = history.joinToString(",") { "\"$it\"" }
|
||||
val content = """{"version":"0","kind":1,"fen":"$fen","move":"${history.last()}","history":[$historyJson]}"""
|
||||
return JesterEvent(
|
||||
id = id,
|
||||
pubKey = testPubkey,
|
||||
createdAt = 1000L + history.size * 1000,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("e", startEventId),
|
||||
arrayOf("e", "prev-move"),
|
||||
arrayOf("p", opponentPubkey),
|
||||
),
|
||||
content = content,
|
||||
sig = "sig",
|
||||
)
|
||||
}
|
||||
|
||||
private fun createTestMoveEventWithResult(
|
||||
id: String,
|
||||
history: List<String>,
|
||||
result: String,
|
||||
): JesterEvent {
|
||||
val historyJson = history.joinToString(",") { "\"$it\"" }
|
||||
val content = """{"version":"0","kind":1,"fen":"test-fen","move":"${history.last()}","history":[$historyJson],"result":"$result","termination":"checkmate"}"""
|
||||
return JesterEvent(
|
||||
id = id,
|
||||
pubKey = testPubkey,
|
||||
createdAt = 1000L + history.size * 1000,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("e", startEventId),
|
||||
arrayOf("e", "prev-move"),
|
||||
arrayOf("p", opponentPubkey),
|
||||
),
|
||||
content = content,
|
||||
sig = "sig",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip64Chess
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Comprehensive tests for PGN parsing per NIP-64 specification
|
||||
*
|
||||
* Tests cover:
|
||||
* - PGN metadata extraction
|
||||
* - Move parsing in Standard Algebraic Notation
|
||||
* - Game result parsing
|
||||
* - Comments and variations handling
|
||||
* - Edge cases and error handling
|
||||
*/
|
||||
class PGNParserTest {
|
||||
// Test 1: Parse minimal PGN (NIP-64 requirement: accept import format)
|
||||
@Test
|
||||
fun `parse minimal PGN with single move`() {
|
||||
val pgn = "1. e4 *"
|
||||
val result = PGNParser.parse(pgn)
|
||||
|
||||
assertTrue(result.isSuccess, "Should successfully parse minimal PGN")
|
||||
val game = result.getOrThrow()
|
||||
assertEquals(1, game.moves.size, "Should have 1 move")
|
||||
assertEquals("e4", game.moves[0].san)
|
||||
assertEquals(GameResult.IN_PROGRESS, game.result)
|
||||
}
|
||||
|
||||
// Test 2: Parse complete game with metadata (NIP-64 requirement)
|
||||
@Test
|
||||
fun `parse PGN with full metadata tags`() {
|
||||
val 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 e5 2. Nf3 Nc6 3. Bb5 a6 1/2-1/2
|
||||
""".trimIndent()
|
||||
|
||||
val result = PGNParser.parse(pgn)
|
||||
assertTrue(result.isSuccess)
|
||||
val game = result.getOrThrow()
|
||||
|
||||
// Verify metadata extraction
|
||||
assertEquals("F/S Return Match", game.event)
|
||||
assertEquals("Belgrade, Serbia JUG", game.site)
|
||||
assertEquals("1992.11.04", game.date)
|
||||
assertEquals("29", game.round)
|
||||
assertEquals("Fischer, Robert J.", game.white)
|
||||
assertEquals("Spassky, Boris V.", game.black)
|
||||
assertEquals(GameResult.DRAW, game.result)
|
||||
|
||||
// Verify moves
|
||||
assertEquals(6, game.moves.size)
|
||||
assertEquals("e4", game.moves[0].san)
|
||||
assertEquals("Nf3", game.moves[2].san)
|
||||
}
|
||||
|
||||
// Test 3: Scholar's Mate (4 move checkmate)
|
||||
@Test
|
||||
fun `parse scholars mate with checkmate notation`() {
|
||||
val pgn =
|
||||
"""
|
||||
[Event "Scholar's Mate"]
|
||||
[White "Alice"]
|
||||
[Black "Bob"]
|
||||
[Result "1-0"]
|
||||
|
||||
1. e4 e5 2. Bc4 Nc6 3. Qh5 Nf6 4. Qxf7# 1-0
|
||||
""".trimIndent()
|
||||
|
||||
val result = PGNParser.parse(pgn)
|
||||
assertTrue(result.isSuccess)
|
||||
val game = result.getOrThrow()
|
||||
|
||||
assertEquals(7, game.moves.size)
|
||||
assertEquals(GameResult.WHITE_WINS, game.result)
|
||||
|
||||
// Verify last move has checkmate marker
|
||||
val lastMove = game.moves.last()
|
||||
assertEquals("Qxf7#", lastMove.san)
|
||||
assertTrue(lastMove.isCheckmate, "Last move should be checkmate")
|
||||
assertTrue(lastMove.isCapture, "Last move should be capture")
|
||||
}
|
||||
|
||||
// Test 4: Fool's Mate (2 move checkmate)
|
||||
@Test
|
||||
fun `parse fools mate shortest checkmate`() {
|
||||
val pgn =
|
||||
"""
|
||||
1. f3 e5 2. g4 Qh4# 0-1
|
||||
""".trimIndent()
|
||||
|
||||
val result = PGNParser.parse(pgn)
|
||||
assertTrue(result.isSuccess)
|
||||
val game = result.getOrThrow()
|
||||
|
||||
assertEquals(4, game.moves.size)
|
||||
assertEquals(GameResult.BLACK_WINS, game.result)
|
||||
|
||||
val lastMove = game.moves.last()
|
||||
assertEquals("Qh4#", lastMove.san)
|
||||
assertTrue(lastMove.isCheckmate)
|
||||
}
|
||||
|
||||
// Test 5: Castling notation
|
||||
@Test
|
||||
fun `parse castling moves kingside and queenside`() {
|
||||
val pgn = "1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. O-O O-O 5. d3 d6 6. c3 a6 7. a4 a5 8. O-O-O *"
|
||||
|
||||
val result = PGNParser.parse(pgn)
|
||||
assertTrue(result.isSuccess)
|
||||
val game = result.getOrThrow()
|
||||
|
||||
// Find castling moves
|
||||
val kingsideCastling = game.moves.filter { it.san == "O-O" }
|
||||
val queensideCastling = game.moves.filter { it.san == "O-O-O" }
|
||||
|
||||
assertEquals(2, kingsideCastling.size, "Should have 2 kingside castling moves")
|
||||
assertEquals(1, queensideCastling.size, "Should have 1 queenside castling move")
|
||||
|
||||
kingsideCastling.forEach { move ->
|
||||
assertTrue(move.isCastling, "O-O should be marked as castling")
|
||||
assertEquals(PieceType.KING, move.piece)
|
||||
}
|
||||
|
||||
queensideCastling.forEach { move ->
|
||||
assertTrue(move.isCastling, "O-O-O should be marked as castling")
|
||||
assertEquals(PieceType.KING, move.piece)
|
||||
}
|
||||
}
|
||||
|
||||
// Test 6: Pawn promotion
|
||||
@Test
|
||||
fun `parse pawn promotion to queen`() {
|
||||
val pgn =
|
||||
"""
|
||||
[Event "Promotion Example"]
|
||||
|
||||
1. e4 d5 2. exd5 Qxd5 3. Nc3 Qa5 4. d4 c6 5. Nf3 Bg4 6. Bf4 e6
|
||||
7. h3 Bxf3 8. Qxf3 Bb4 9. Be2 Nd7 10. a3 O-O-O 11. axb4 Qxa1+
|
||||
12. Kd2 Qxh1 13. Qxh1 a6 14. c4 f6 15. b5 axb5 16. cxb5 c5
|
||||
17. b6 Ne7 18. Qh2 h6 19. dxc5 Nxc5 20. b3 Kc8 21. Qg3 Ncd7
|
||||
22. Bd6 Nf5 23. Qf4 Nxd6 24. Qxd6 Nb8 25. Kc3 Rh7 26. Kb4 Rd7
|
||||
27. Qc5+ Kd8 28. Bf3 Ke8 29. Bd5 exd5 30. Nxd5 Kf7 31. b7 Kg6
|
||||
32. b8=Q *
|
||||
""".trimIndent()
|
||||
|
||||
val result = PGNParser.parse(pgn)
|
||||
assertTrue(result.isSuccess)
|
||||
val game = result.getOrThrow()
|
||||
|
||||
// Find promotion move
|
||||
val promotionMove = game.moves.firstOrNull { it.promotion != null }
|
||||
assertNotNull(promotionMove, "Should have promotion move")
|
||||
assertEquals(PieceType.QUEEN, promotionMove.promotion)
|
||||
assertTrue(promotionMove.san.contains("=Q"), "Promotion move should contain =Q")
|
||||
}
|
||||
|
||||
// Test 7: Captures
|
||||
@Test
|
||||
fun `parse capture notation`() {
|
||||
val pgn = "1. e4 d5 2. exd5 Qxd5 3. Nc3 Qxd4 *"
|
||||
|
||||
val result = PGNParser.parse(pgn)
|
||||
assertTrue(result.isSuccess)
|
||||
val game = result.getOrThrow()
|
||||
|
||||
val captures = game.moves.filter { it.isCapture }
|
||||
assertEquals(3, captures.size, "Should have 3 capture moves")
|
||||
|
||||
captures.forEach { move ->
|
||||
assertTrue(move.san.contains("x"), "Capture moves should contain 'x'")
|
||||
}
|
||||
}
|
||||
|
||||
// Test 8: Check notation
|
||||
@Test
|
||||
fun `parse check and checkmate markers`() {
|
||||
val pgn = "1. e4 e5 2. Qh5 Nc6 3. Bc4 Nf6 4. Qxf7# 1-0"
|
||||
|
||||
val result = PGNParser.parse(pgn)
|
||||
assertTrue(result.isSuccess)
|
||||
val game = result.getOrThrow()
|
||||
|
||||
val checkMoves = game.moves.filter { it.isCheck }
|
||||
val checkmateMoves = game.moves.filter { it.isCheckmate }
|
||||
|
||||
assertTrue(checkmateMoves.isNotEmpty(), "Should have checkmate moves")
|
||||
checkmateMoves.forEach { move ->
|
||||
assertTrue(move.san.contains("#"), "Checkmate moves should contain #")
|
||||
}
|
||||
}
|
||||
|
||||
// Test 9: Comments and variations (NIP-64: should handle PGN comments)
|
||||
@Test
|
||||
fun `parse PGN with comments and variations stripped`() {
|
||||
val pgn =
|
||||
"""
|
||||
1. e4 {Best by test} e5 (1...c5 2. Nf3) 2. Nf3 Nc6 {Developing} 3. Bb5 *
|
||||
""".trimIndent()
|
||||
|
||||
val result = PGNParser.parse(pgn)
|
||||
assertTrue(result.isSuccess)
|
||||
val game = result.getOrThrow()
|
||||
|
||||
// Comments and variations should be stripped
|
||||
assertEquals(5, game.moves.size)
|
||||
assertEquals("e4", game.moves[0].san)
|
||||
assertEquals("e5", game.moves[1].san)
|
||||
assertEquals("Nf3", game.moves[2].san)
|
||||
}
|
||||
|
||||
// Test 10: NAG annotations (Numeric Annotation Glyphs)
|
||||
@Test
|
||||
fun `parse PGN with NAG annotations`() {
|
||||
val pgn = "1. e4$1 e5$6 2. Nf3$10 *"
|
||||
|
||||
val result = PGNParser.parse(pgn)
|
||||
assertTrue(result.isSuccess)
|
||||
val game = result.getOrThrow()
|
||||
|
||||
assertEquals(3, game.moves.size)
|
||||
}
|
||||
|
||||
// Test 11: Disambiguating moves
|
||||
@Test
|
||||
fun `parse moves with disambiguation`() {
|
||||
val pgn =
|
||||
"""
|
||||
1. Nf3 Nf6 2. Nc3 Nc6 3. d4 d5 4. Bf4 Bf5 5. e3 e6
|
||||
6. Nbd2 Nbd7 7. Bd3 Bd6 *
|
||||
""".trimIndent()
|
||||
|
||||
val result = PGNParser.parse(pgn)
|
||||
assertTrue(result.isSuccess)
|
||||
val game = result.getOrThrow()
|
||||
|
||||
// Check for disambiguated moves (Nbd2, Nbd7)
|
||||
val disambiguatedMoves = game.moves.filter { it.fromSquare != null }
|
||||
assertTrue(disambiguatedMoves.isNotEmpty(), "Should have disambiguated moves")
|
||||
}
|
||||
|
||||
// Test 12: All possible game results
|
||||
@Test
|
||||
fun `parse all game result notations`() {
|
||||
val whiteWins = "1. e4 e5 2. Qh5 Nc6 3. Bc4 Nf6 4. Qxf7# 1-0"
|
||||
val blackWins = "1. f3 e5 2. g4 Qh4# 0-1"
|
||||
val draw = "1. e4 e5 2. Nf3 Nc6 1/2-1/2"
|
||||
val inProgress = "1. e4 e5 *"
|
||||
|
||||
assertEquals(GameResult.WHITE_WINS, PGNParser.parse(whiteWins).getOrThrow().result)
|
||||
assertEquals(GameResult.BLACK_WINS, PGNParser.parse(blackWins).getOrThrow().result)
|
||||
assertEquals(GameResult.DRAW, PGNParser.parse(draw).getOrThrow().result)
|
||||
assertEquals(GameResult.IN_PROGRESS, PGNParser.parse(inProgress).getOrThrow().result)
|
||||
}
|
||||
|
||||
// Test 13: Empty/invalid PGN handling
|
||||
@Test
|
||||
fun `handle empty PGN gracefully`() {
|
||||
val emptyPgn = ""
|
||||
val result = PGNParser.parse(emptyPgn)
|
||||
|
||||
// Should not crash, might return empty game
|
||||
assertTrue(result.isSuccess || result.isFailure)
|
||||
}
|
||||
|
||||
// Test 14: Position generation
|
||||
@Test
|
||||
fun `generate positions for each move`() {
|
||||
val pgn = "1. e4 e5 2. Nf3 Nc6 3. Bb5 *"
|
||||
|
||||
val result = PGNParser.parse(pgn)
|
||||
assertTrue(result.isSuccess)
|
||||
val game = result.getOrThrow()
|
||||
|
||||
// Should have starting position + one per move
|
||||
assertEquals(game.moves.size + 1, game.positions.size)
|
||||
|
||||
// First position should be starting position
|
||||
val startPos = game.positions[0]
|
||||
assertEquals(Color.WHITE, startPos.activeColor)
|
||||
assertEquals(1, startPos.moveNumber)
|
||||
}
|
||||
|
||||
// Test 15: Verify required metadata (NIP-64: PGN should have standard tags)
|
||||
@Test
|
||||
fun `detect presence of required PGN metadata tags`() {
|
||||
val fullPgn =
|
||||
"""
|
||||
[Event "FIDE World Championship"]
|
||||
[Site "London"]
|
||||
[Date "2018.11.28"]
|
||||
[Round "12"]
|
||||
[White "Carlsen, Magnus"]
|
||||
[Black "Caruana, Fabiano"]
|
||||
[Result "1-0"]
|
||||
|
||||
1. e4 *
|
||||
""".trimIndent()
|
||||
|
||||
val minimalPgn = "1. e4 *"
|
||||
|
||||
val fullGame = PGNParser.parse(fullPgn).getOrThrow()
|
||||
val minimalGame = PGNParser.parse(minimalPgn).getOrThrow()
|
||||
|
||||
assertTrue(fullGame.hasRequiredMetadata(), "Full PGN should have required metadata")
|
||||
assertFalse(minimalGame.hasRequiredMetadata(), "Minimal PGN should not have required metadata")
|
||||
}
|
||||
|
||||
// Test 16: Long tournament game
|
||||
@Test
|
||||
fun `parse realistic tournament game`() {
|
||||
val pgn =
|
||||
"""
|
||||
[Event "Wch"]
|
||||
[Site "New York"]
|
||||
[Date "1886.??.??"]
|
||||
[Round "1"]
|
||||
[White "Zukertort, Johannes"]
|
||||
[Black "Steinitz, William"]
|
||||
[Result "0-1"]
|
||||
|
||||
1. d4 d5 2. c4 e6 3. Nc3 Nf6 4. e3 c5 5. Nf3 Nc6 6. a3 dxc4
|
||||
7. Bxc4 cxd4 8. exd4 Be7 9. O-O O-O 10. Qd3 Bd7 11. Qe2 Qb8
|
||||
12. Rd1 Rd8 13. Be3 Be8 14. Ne5 Nxe5 15. dxe5 Rxd1+ 16. Rxd1 Nd7
|
||||
17. f4 Nc5 18. Qf2 Rc8 19. b4 Na6 20. Bd3 Nb8 21. Ne4 Nc6
|
||||
22. Nd6 Bxd6 23. exd6 Qxd6 24. Bxh7+ Kh8 25. Bf5 Qc7 26. Bxc8 Qxc8
|
||||
27. Qd2 Bg6 28. Qd7 Qxd7 29. Rxd7 b6 30. Bc1 Nd8 31. Rxd8+ 0-1
|
||||
""".trimIndent()
|
||||
|
||||
val result = PGNParser.parse(pgn)
|
||||
assertTrue(result.isSuccess)
|
||||
val game = result.getOrThrow()
|
||||
|
||||
assertEquals("Zukertort, Johannes", game.white)
|
||||
assertEquals("Steinitz, William", game.black)
|
||||
assertEquals(GameResult.BLACK_WINS, game.result)
|
||||
assertTrue(game.moves.size > 50, "Tournament game should have many moves")
|
||||
}
|
||||
|
||||
// Test 17: Alternative castling notation (0-0 instead of O-O)
|
||||
@Test
|
||||
fun `parse alternative castling notation with zeros`() {
|
||||
val pgn = "1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. 0-0 0-0 *"
|
||||
|
||||
val result = PGNParser.parse(pgn)
|
||||
assertTrue(result.isSuccess)
|
||||
val game = result.getOrThrow()
|
||||
|
||||
val castlingMoves = game.moves.filter { it.isCastling }
|
||||
assertEquals(2, castlingMoves.size)
|
||||
}
|
||||
|
||||
// Test 18: Move count verification
|
||||
@Test
|
||||
fun `verify move numbers are correct`() {
|
||||
val pgn = "1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 *"
|
||||
|
||||
val result = PGNParser.parse(pgn)
|
||||
assertTrue(result.isSuccess)
|
||||
val game = result.getOrThrow()
|
||||
|
||||
assertEquals(6, game.moves.size)
|
||||
|
||||
// Verify move numbers
|
||||
assertEquals(1, game.moves[0].moveNumber) // e4
|
||||
assertEquals(1, game.moves[1].moveNumber) // e5
|
||||
assertEquals(2, game.moves[2].moveNumber) // Nf3
|
||||
assertEquals(2, game.moves[3].moveNumber) // Nc6
|
||||
assertEquals(3, game.moves[4].moveNumber) // Bb5
|
||||
assertEquals(3, game.moves[5].moveNumber) // a6
|
||||
}
|
||||
|
||||
// Test 19: Move colors are correct
|
||||
@Test
|
||||
fun `verify move colors alternate correctly`() {
|
||||
val pgn = "1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 *"
|
||||
|
||||
val result = PGNParser.parse(pgn)
|
||||
assertTrue(result.isSuccess)
|
||||
val game = result.getOrThrow()
|
||||
|
||||
assertEquals(Color.WHITE, game.moves[0].color)
|
||||
assertEquals(Color.BLACK, game.moves[1].color)
|
||||
assertEquals(Color.WHITE, game.moves[2].color)
|
||||
assertEquals(Color.BLACK, game.moves[3].color)
|
||||
assertEquals(Color.WHITE, game.moves[4].color)
|
||||
assertEquals(Color.BLACK, game.moves[5].color)
|
||||
}
|
||||
|
||||
// Test 20: Piece type detection
|
||||
@Test
|
||||
fun `detect piece types from SAN notation`() {
|
||||
val pgn = "1. e4 e5 2. Nf3 Nc6 3. Bb5 Bc5 4. Qa4 Qf6 5. Ke2 Ke7 6. Ra3 Ra6 *"
|
||||
|
||||
val result = PGNParser.parse(pgn)
|
||||
assertTrue(result.isSuccess)
|
||||
val game = result.getOrThrow()
|
||||
|
||||
// e4, e5 - pawns
|
||||
assertEquals(PieceType.PAWN, game.moves[0].piece)
|
||||
assertEquals(PieceType.PAWN, game.moves[1].piece)
|
||||
|
||||
// Nf3, Nc6 - knights
|
||||
assertEquals(PieceType.KNIGHT, game.moves[2].piece)
|
||||
assertEquals(PieceType.KNIGHT, game.moves[3].piece)
|
||||
|
||||
// Bb5, Bc5 - bishops
|
||||
assertEquals(PieceType.BISHOP, game.moves[4].piece)
|
||||
assertEquals(PieceType.BISHOP, game.moves[5].piece)
|
||||
|
||||
// Qa4, Qf6 - queens
|
||||
assertEquals(PieceType.QUEEN, game.moves[6].piece)
|
||||
assertEquals(PieceType.QUEEN, game.moves[7].piece)
|
||||
|
||||
// Ke2, Ke7 - kings
|
||||
assertEquals(PieceType.KING, game.moves[8].piece)
|
||||
assertEquals(PieceType.KING, game.moves[9].piece)
|
||||
|
||||
// Ra3, Ra6 - rooks
|
||||
assertEquals(PieceType.ROOK, game.moves[10].piece)
|
||||
assertEquals(PieceType.ROOK, game.moves[11].piece)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.utils
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFails
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class GZipTest {
|
||||
@Test
|
||||
fun roundTripSimpleString() {
|
||||
val original = "Hello, Nostr!"
|
||||
val decompressed = GZip.decompress(GZip.compress(original))
|
||||
assertEquals(original, decompressed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun roundTripEmptyString() {
|
||||
val original = ""
|
||||
val decompressed = GZip.decompress(GZip.compress(original))
|
||||
assertEquals(original, decompressed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun roundTripLongRepetitiveString() {
|
||||
val original = "abcdefghij".repeat(1000)
|
||||
val compressed = GZip.compress(original)
|
||||
val decompressed = GZip.decompress(compressed)
|
||||
assertEquals(original, decompressed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun compressedSizeIsSmallerForRepetitiveInput() {
|
||||
val original = "abcdefghij".repeat(1000)
|
||||
val compressed = GZip.compress(original)
|
||||
assertTrue(
|
||||
compressed.size < original.encodeToByteArray().size,
|
||||
"Expected compressed size (${compressed.size}) to be smaller than original (${original.encodeToByteArray().size})",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun roundTripUnicodeString() {
|
||||
val original = "こんにちは世界 🌍 مرحبا Привет"
|
||||
val decompressed = GZip.decompress(GZip.compress(original))
|
||||
assertEquals(original, decompressed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun roundTripNostrJsonEvent() {
|
||||
val original =
|
||||
"""{"id":"abc123","pubkey":"deadbeef","created_at":1700000000,"kind":1,""" +
|
||||
""""tags":[],"content":"Hello world","sig":"cafebabe"}"""
|
||||
val decompressed = GZip.decompress(GZip.compress(original))
|
||||
assertEquals(original, decompressed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun compressedBytesStartWithGzipMagicNumber() {
|
||||
// gzip streams always begin with 0x1F 0x8B
|
||||
val compressed = GZip.compress("test")
|
||||
assertTrue(compressed.size >= 2, "Compressed output too short to contain gzip header")
|
||||
assertEquals(0x1F.toByte(), compressed[0], "Expected gzip magic byte 0 (0x1F)")
|
||||
assertEquals(0x8B.toByte(), compressed[1], "Expected gzip magic byte 1 (0x8B)")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun compressedOutputDiffersFromInput() {
|
||||
val original = "Hello, Nostr!"
|
||||
val compressed = GZip.compress(original)
|
||||
assertTrue(
|
||||
!compressed.contentEquals(original.encodeToByteArray()),
|
||||
"Compressed output should differ from the raw input bytes",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decompressInvalidDataThrows() {
|
||||
val garbage = byteArrayOf(0x00, 0x01, 0x02, 0x03, 0x04)
|
||||
assertFails { GZip.decompress(garbage) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun roundTripSingleCharacter() {
|
||||
val original = "A"
|
||||
assertEquals(original, GZip.decompress(GZip.compress(original)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun roundTripSpecialCharacters() {
|
||||
val original = "\t\n\r\u0000\u001F\u007F"
|
||||
assertEquals(original, GZip.decompress(GZip.compress(original)))
|
||||
}
|
||||
}
|
||||
@@ -20,10 +20,28 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.utils
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.DeterministicSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.EventAssembler
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes
|
||||
|
||||
fun String.nsecToKeyPair() = KeyPair(this.bechToBytes())
|
||||
|
||||
fun String.nsecToSigner() = this.nsecToKeyPair().let { DeterministicSigner(it) }
|
||||
|
||||
class DeterministicSigner(
|
||||
val key: KeyPair,
|
||||
val pubKey: HexKey = key.pubKey.toHexKey(),
|
||||
) {
|
||||
fun <T : Event> sign(
|
||||
createdAt: Long,
|
||||
kind: Int,
|
||||
tags: Array<Array<String>>,
|
||||
content: String,
|
||||
): T = EventAssembler.hashAndSign(pubKey, createdAt, kind, tags, content, key.privKey!!, nonce = null)
|
||||
|
||||
fun <T : Event> sign(ev: EventTemplate<T>): T = sign(ev.createdAt, ev.kind, ev.tags, ev.content)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user