436 lines
21 KiB
Kotlin
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")
|
|
}
|
|
}
|
|
|
|
}
|