Files
pairgoth/api-webapp/src/test/kotlin/PairingTests.kt
Claude Brisson e8fc9c46b3 Fix tests
2025-05-16 22:50:58 +02:00

436 lines
21 KiB
Kotlin

package org.jeudego.pairgoth.test
import com.republicate.kson.Json
import org.jeudego.pairgoth.model.*
import org.jeudego.pairgoth.pairing.solver.BaseSolver
import org.jeudego.pairgoth.store.MemoryStore
import org.jeudego.pairgoth.store.lastPlayerId
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.io.File
import java.io.FileWriter
import java.io.PrintWriter
import java.nio.charset.StandardCharsets
import java.text.DecimalFormat
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.min
import kotlin.test.assertTrue
//@Disabled("pairings differ")
class PairingTests: TestBase() {
@BeforeEach
fun reset() {
MemoryStore.reset()
}
companion object {
fun create_weights_map(file: File): HashMap<Pair<String, String>, List<Double>> {
val map = HashMap<Pair<String, String>, List<Double>>()
// Read lines
val lines = file.readLines()
// Store headers
val header1 = lines[0]
val header2 = lines[1]
logger.info("Reading weights file "+file)
// Loop through sections
for (i in 2..lines.size-1 step 12) {
// Get name pair
val name1 = lines[i].split("=")[1]
val name2 = lines[i+1].split("=")[1]
// Nested loop over costs
val costs = mutableListOf<Double>()
for (j in i + 2..i + 11) {
val parts = lines[j].split("=")
costs.add(parts[1].toDouble())
}
val tmp_pair = if (name1 > name2) Pair(name1,name2) else Pair(name2,name1)
// Add to map
map[tmp_pair] = costs
}
return map
}
fun compare_weights(file1: File, file2: File, skipSeeding: Boolean = false):Boolean {
BaseSolver.weightsLogger!!.flush()
// Maps to store name pairs and costs
val map1 = create_weights_map(file1)
val map2 = create_weights_map(file2)
var identical = true
for ((key, value) in map1) {
// Check if key exists in both
if (map2.containsKey(key)) {
// Compare values
//logger.info("Comparing $key")
val isValid = if (!skipSeeding) {
abs(value!![9] - map2[key]!![9])>10 && identical==true
} else {
abs((value!![9]-value!![6]-value!![5]) - (map2[key]!![9]-map2[key]!![6]-map2[key]!![5]))>10 && identical==true
}
if (isValid) {
// Key exists but values differ - print key
logger.info("Difference found at $key")
logger.info(" pairgoth opengotha")
logger.info("baseDuplicateGameCost = "+value!![0].toString()+" "+map2[key]!![0].toString())
logger.info("baseRandomCost = "+value!![1].toString()+" "+map2[key]!![1].toString())
logger.info("baseBWBalanceCost = "+value!![2].toString()+" "+map2[key]!![2].toString())
logger.info("mainCategoryCost = "+value!![3].toString()+" "+map2[key]!![3].toString())
logger.info("mainScoreDiffCost = "+value!![4].toString()+" "+map2[key]!![4].toString())
logger.info("mainDUDDCost = "+value!![5].toString()+" "+map2[key]!![5].toString())
logger.info("mainSeedCost = "+value!![6].toString()+" "+map2[key]!![6].toString())
logger.info("secHandiCost = "+value!![7].toString()+" "+map2[key]!![7].toString())
logger.info("secGeoCost = "+value!![8].toString()+" "+map2[key]!![8].toString())
logger.info("totalCost = "+value!![9].toString()+" "+map2[key]!![9].toString())
identical = false
}
}
}
return identical
}
}
fun compare_games(games:Json.Array, opengotha:Json.Array, skipColor: Boolean = false): Boolean{
if (games.size != opengotha.size) {
val tmp = Game.fromJson(games.getJson(games.size-1)!!.asObject())
if ((tmp.white != 0) and (tmp.black != 0)) {return false}
}
val gamesPair = mutableSetOf<Pair<ID,ID>>()
val openGothaPair = mutableSetOf<Pair<ID,ID>>()
for (i in 0 until opengotha.size) {
val tmp = Game.fromJson(games.getJson(i)!!.asObject().let {
Json.MutableObject(it).set("t", 0) // hack to fill the table to make fromJson() happy
})
val tmpOG = Game.fromJson(opengotha.getJson(i)!!.asObject().let {
Json.MutableObject(it).set("t", 0) // hack to fill the table to make fromJson() happy
})
if (skipColor) {
gamesPair.add(Pair(min(tmp.white, tmp.black), max(tmp.white, tmp.black)))
openGothaPair.add(Pair(min(tmpOG.white, tmpOG.black), max(tmpOG.white, tmpOG.black)))
} else {
gamesPair.add(Pair(tmp.white, tmp.black))
openGothaPair.add(Pair(tmpOG.white, tmpOG.black))
}
}
if (gamesPair!=openGothaPair) {
logger.info("Pairings do not match "+gamesPair.asSequence().minus(openGothaPair).map {it}.toList().toString())
}
return gamesPair==openGothaPair
}
fun formatPlayer(p: Json.Object): String {
val player = p as Json.Object
return "${p.getString("name")} ${p.getString("firstname")} ${displayRank(p.getInt("rank")!!)}"
}
fun formatGame(playersMap: Map<Long, String>, g: Any?): String {
val game = g as Json.Object
return "${g.getInt("t")}. white ${playersMap[g.getLong("w")!!] ?: "BIP"} vs. black ${playersMap[g.getLong("b")!!] ?: "BIP"} h${g.getInt("h") ?: 0}"
}
fun compute_sumOfWeight_OG(file: File, opengotha: Json.Array, players: Json.Array): Double{
// Map to store name pairs and costs
val map = create_weights_map(file)
val mapNamesID = HashMap<Int?, String>()
for (i in 0 until players.size) {
val tmpPairable = players.getJson(i)!!.asObject()
val id: Int? = tmpPairable.getID("id")
val name = tmpPairable.getString("name")+" "+tmpPairable.getString("firstname")
mapNamesID[id] = name
}
val ngames = if ("\"b\":0" in opengotha.takeLast(1).toString()) opengotha.size-1 else opengotha.size // remove games with bye player
var sumOfWeights = 0.0
for (i in 0 until ngames) {
val tmpOG = Game.fromJson(opengotha.getJson(i)!!.asObject().let {
Json.MutableObject(it).set("t", 0) // hack to fill the table to make fromJson() happy
})
val name1 = mapNamesID[tmpOG.white]!!
val name2 = mapNamesID[tmpOG.black]!!
val namePair = if (name1 > name2) Pair(name1,name2) else Pair(name2,name1)
val cost = map[namePair]!![9]
sumOfWeights += cost
}
return sumOfWeights
}
fun test_from_XML(name: String, forcePairing:List<Int>) {
test_from_XML_internal(name, forcePairing, true)
// Non-legacy tests inhibited for now: pairings differ for Toulouse and SimpleMM
// test_from_XML_internal(name, forcePairing, false)
}
fun test_from_XML_internal(name: String, forcePairing:List<Int>, legacy: Boolean) {
// Let pairgoth use the legacy asymmetric detRandom()
BaseSolver.legacy_mode = legacy
// read tournament with pairing
val file = getTestFile("opengotha/pairings/$name.xml")
logger.info("read from file $file")
val resource = file.readText(StandardCharsets.UTF_8)
var resp = TestAPI.post("/api/tour", resource)
val id = resp.asObject().getInt("id")
val tournament = TestAPI.get("/api/tour/$id").asObject()
logger.info(tournament.toString().slice(0..50) + "...")
val players = TestAPI.get("/api/tour/$id/part").asArray()
logger.info(players.toString().slice(0..50) + "...")
// Get pairings (including results) from OpenGotha file
val pairingsOG = mutableListOf<Json.Array>()
for (round in 1..tournament.getInt("rounds")!!) {
val games = TestAPI.get("/api/tour/$id/res/$round").asArray()
pairingsOG.add(games)
}
// Delete pairings
for (round in tournament.getInt("rounds")!! downTo 1) {
TestAPI.delete("/api/tour/$id/pair/$round", Json.Array("all"))
}
val dec = DecimalFormat("#.#")
var games: Json.Array
var firstGameID: Int
for (round in 1..tournament.getInt("rounds")!!) {
val sumOfWeightsOG = compute_sumOfWeight_OG(getTestFile("opengotha/$name/$name" + "_weights_R$round.txt"), pairingsOG[round-1], players)
BaseSolver.weightsLogger = PrintWriter(FileWriter(getOutputFile("weights.txt")))
// Call Pairgoth pairing solver to generate games
games = TestAPI.post("/api/tour/$id/pair/$round", Json.Array("all")).asArray()
logger.info("sumOfWeightOG = " + dec.format(sumOfWeightsOG))
logger.info("games for round $round: {}", games.toString())
// Compare weights with OpenGotha if legacy mode
if (legacy) {
assertTrue(compare_weights(getOutputFile("weights.txt"), getTestFile("opengotha/$name/$name"+"_weights_R$round.txt")), "Not matching opengotha weights for round $round")
}
if (round in forcePairing) {
logger.info("Non unique pairing, forcing Opengotha pairing to Pairgoth")
firstGameID = (games.getJson(0)!!.asObject()["id"] as Long?)!!.toInt()
for (i in 0 until pairingsOG[round - 1].size) {
val gameID = firstGameID + i
// find corresponding game (matching white id)
val gameOG = pairingsOG[round - 1].getJson(i)!!.asObject()// ["r"] as String?
val whiteId = gameOG["w"] as Long?
val blackId = gameOG["b"] as Long?
TestAPI.put("/api/tour/$id/pair/$round", Json.parse("""{"id":$gameID,"w":$whiteId,"b":$blackId}""")).asObject()
}
games = TestAPI.get("/api/tour/$id/res/$round").asArray()
}
// Compare pairings with OpenGotha
val gamesDoMatch = compare_games(games, pairingsOG[round - 1])
if (!gamesDoMatch) {
// give a nice error message
val playersMap = players.associate { p ->
val player = p as Json.Object
Pair(player.getLong("id")!!, formatPlayer(player))
}
logger.info("Expected opengotha pairing:\n${
pairingsOG[round - 1].joinToString("\n") {
val game = it as Json.Object
formatGame(playersMap, game)
}
}")
logger.info("Actual pairgoth pairing:\n${
games.joinToString("\n") {
val game = it as Json.Object
formatGame(playersMap, game)
}
}")
}
assertTrue(gamesDoMatch, "pairings for round $round differ")
logger.info("Pairings for round $round match OpenGotha")
// Enter results extracted from OpenGotha
firstGameID = (games.getJson(0)!!.asObject()["id"] as Long?)!!.toInt()
for (i in 0 until pairingsOG[round - 1].size) {
val gameID = firstGameID + i
// find corresponding game (matching white id)
for (j in 0 until pairingsOG[round - 1].size) {
val gameOG = pairingsOG[round - 1].getJson(j)!!.asObject()// ["r"] as String?
if (gameOG["w"] == games.getJson(i)!!.asObject()["w"]) {
val gameRes = gameOG["r"] as String?
resp = TestAPI.put("/api/tour/$id/res/$round", Json.parse("""{"id":$gameID,"result":"$gameRes"}""")).asObject()
assertTrue(resp.getBoolean("success") == true, "expecting success")
break
}
}
}
logger.info("Results succesfully entered for round $round")
}
}
@Test
fun `SwissTest simpleSwiss`() {
BaseSolver.legacy_mode = true
// read tournament with pairing
var file = getTestFile("opengotha/pairings/simpleswiss.xml")
logger.info("read from file $file")
val resource = file.readText(StandardCharsets.UTF_8)
var resp = TestAPI.post("/api/tour", resource)
val id = resp.asObject().getInt("id")
val tournament = TestAPI.get("/api/tour/$id").asObject()
logger.info(tournament.toString().slice(0..50) + "...")
val players = TestAPI.get("/api/tour/$id/part").asArray()
logger.info(players.toString().slice(0..50) + "...")
val pairingsOG = mutableListOf<String>()
for (round in 1..tournament.getInt("rounds")!!) {
val games = TestAPI.get("/api/tour/$id/res/$round").asArray()
logger.info("games for round $round: {}", games.toString().slice(0..50) + "...")
pairingsOG.add(games.toString())
}
for (round in tournament.getInt("rounds")!! downTo 1) {
TestAPI.delete("/api/tour/$id/pair/$round", Json.Array("all"))
}
/*
At least 2 solutions have the same sum of weights, OpenGotha pairing is
"""[{"id":698,"t":1,"w":335,"b":324,"h":0,"r":"b","dd":0},{"id":699,"t":2,"w":316,"b":311,"h":0,"r":"b","dd":0},{"id":700,"t":3,"w":337,"b":325,"h":0,"r":"b","dd":0},{"id":701,"t":4,"w":313,"b":326,"h":0,"r":"b","dd":0},{"id":702,"t":5,"w":321,"b":309,"h":0,"r":"b","dd":0},{"id":703,"t":6,"w":319,"b":317,"h":0,"r":"b","dd":0},{"id":704,"t":7,"w":340,"b":312,"h":0,"r":"b","dd":0},{"id":705,"t":8,"w":332,"b":318,"h":0,"r":"b","dd":0},{"id":706,"t":9,"w":336,"b":327,"h":0,"r":"b","dd":0},{"id":707,"t":10,"w":333,"b":322,"h":0,"r":"b","dd":0},{"id":708,"t":11,"w":315,"b":328,"h":0,"r":"b","dd":0},{"id":709,"t":12,"w":323,"b":334,"h":0,"r":"b","dd":0},{"id":710,"t":13,"w":329,"b":331,"h":0,"r":"b","dd":0},{"id":711,"t":14,"w":339,"b":310,"h":0,"r":"b","dd":0},{"id":712,"t":15,"w":338,"b":320,"h":0,"r":"b","dd":0},{"id":713,"t":16,"w":330,"b":314,"h":0,"r":"b","dd":0}]"""
Pairgoth pairing is
*/
pairingsOG[6] = Json.parse(
"""[{"id":810,"t":1,"w":335,"b":324,"h":0,"r":"?","dd":0},{"id":811,"t":2,"w":337,"b":325,"h":0,"r":"?","dd":1},{"id":812,"t":3,"w":316,"b":319,"h":0,"r":"?","dd":1},{"id":813,"t":4,"w":313,"b":326,"h":0,"r":"?","dd":0},{"id":814,"t":5,"w":321,"b":309,"h":0,"r":"?","dd":0},{"id":815,"t":6,"w":311,"b":317,"h":0,"r":"?","dd":1},{"id":816,"t":7,"w":340,"b":312,"h":0,"r":"?","dd":0},{"id":817,"t":8,"w":332,"b":318,"h":0,"r":"?","dd":0},{"id":818,"t":9,"w":336,"b":327,"h":0,"r":"?","dd":0},{"id":819,"t":10,"w":333,"b":322,"h":0,"r":"?","dd":0},{"id":820,"t":11,"w":315,"b":328,"h":0,"r":"?","dd":1},{"id":821,"t":12,"w":323,"b":334,"h":0,"r":"?","dd":0},{"id":822,"t":13,"w":329,"b":331,"h":0,"r":"?","dd":0},{"id":823,"t":14,"w":339,"b":310,"h":0,"r":"?","dd":0},{"id":824,"t":15,"w":338,"b":320,"h":0,"r":"?","dd":0},{"id":825,"t":16,"w":330,"b":314,"h":0,"r":"?","dd":0}]"""
)!!.asArray().mapTo(Json.MutableArray()) {
// adjust ids
Json.MutableObject(it as Json.Object).also { game ->
game["w"] = game.getInt("w")!! - 340 + lastPlayerId
game["b"] = game.getInt("b")!! - 340 + lastPlayerId
}
}.toString()
var games: Json.Array
var firstGameID: Int
for (round in 1..7) {
BaseSolver.weightsLogger = PrintWriter(FileWriter(getOutputFile("weights.txt")))
games = TestAPI.post("/api/tour/$id/pair/$round", Json.Array("all")).asArray()
logger.info("games for round $round: {}", games.toString().slice(0..50) + "...")
assertTrue(compare_weights(getOutputFile("weights.txt"), getTestFile("opengotha/simpleswiss/simpleswiss_weights_R$round.txt")), "Not matching opengotha weights for round $round")
assertTrue(compare_games(games, Json.parse(pairingsOG[round - 1])!!.asArray()),"pairings for round $round differ")
logger.info("Pairings for round $round match OpenGotha")
firstGameID = (games.getJson(0)!!.asObject()["id"] as Long?)!!.toInt()
for (gameID in firstGameID..firstGameID + 15) {
resp = TestAPI.put("/api/tour/$id/res/$round", Json.parse("""{"id":$gameID,"result":"b"}""")).asObject()
assertTrue(resp.getBoolean("success") == true, "expecting success")
}
logger.info("Results succesfully entered for round $round")
}
}
@Test
fun `SwissTest notSoSimpleSwiss`() {
test_from_XML("notsosimpleswiss", listOf(6, 8))
}
@Test
fun `MMtest simpleMM`() {
test_from_XML("simplemm", emptyList())
}
@Test
fun `MMtest notSimpleMM`() {
test_from_XML("notsimplemm", emptyList())
}
@Test
fun `MMtest Toulouse2024`() {
test_from_XML("Toulouse2024", emptyList())
}
@Test
fun `SwissTest KPMCSplitbug`() {
// Let pairgoth use the legacy asymmetric detRandom()
BaseSolver.legacy_mode = true
// read tournament with pairing
val name = "20240921-KPMC-Splitbug"
val file = getTestFile("opengotha/pairings/$name.xml")
logger.info("read from file $file")
val resource = file.readText(StandardCharsets.UTF_8)
var resp = TestAPI.post("/api/tour", resource)
val id = resp.asObject().getInt("id")
val tournament = TestAPI.get("/api/tour/$id").asObject()
logger.info(tournament.toString().slice(0..50) + "...")
val players = TestAPI.get("/api/tour/$id/part").asArray()
logger.info(players.toString().slice(0..50) + "...")
// Set the rounds on which to perform the pairings test
val minRound = 2
val maxRound = 2
// Get pairings (including results) from OpenGotha file
val pairingsOG = mutableListOf<Json.Array>()
for (round in minRound..maxRound) {
val games = TestAPI.get("/api/tour/$id/res/$round").asArray()
pairingsOG.add(games)
}
// Delete pairings
for (round in maxRound downTo minRound) {
TestAPI.delete("/api/tour/$id/pair/$round", Json.Array("all"))
}
val dec = DecimalFormat("#.#")
var games: Json.Array
var firstGameID: Int
for (round in minRound..maxRound) {
val sumOfWeightsOG = compute_sumOfWeight_OG(getTestFile("opengotha/$name/$name" + "_weights_R$round.txt"), pairingsOG[round - minRound], players)
BaseSolver.weightsLogger = PrintWriter(FileWriter(getOutputFile("weights.txt")))
// Call Pairgoth pairing solver to generate games
games = TestAPI.post("/api/tour/$id/pair/$round", Json.Array("all")).asArray()
logger.info("sumOfWeightOG = " + dec.format(sumOfWeightsOG))
logger.info("games for round $round: {}", games.toString().slice(0..50) + "...")
// Compare weights with OpenGotha
assertTrue(
compare_weights(
getOutputFile("weights.txt"),
getTestFile("opengotha/$name/$name" + "_weights_R$round.txt")
), "Not matching opengotha weights for round $round"
)
// Compare pairings with OpenGotha
val gamesDoMatch = compare_games(games, pairingsOG[round - minRound])
if (!gamesDoMatch) {
// give a nice error message
val playersMap = players.associate { p ->
val player = p as Json.Object
Pair(player.getLong("id")!!, formatPlayer(player))
}
logger.info("Expected opengotha pairing:\n${
pairingsOG[round - minRound].joinToString("\n") {
val game = it as Json.Object
formatGame(playersMap, game)
}
}")
logger.info("Actual pairgoth pairing:\n${
games.joinToString("\n") {
val game = it as Json.Object
formatGame(playersMap, game)
}
}")
}
assertTrue(gamesDoMatch, "pairings for round $round differ")
logger.info("Pairings for round $round match OpenGotha")
}
}
}