From 3d06588889aaa2bbdaf171701c39b242eb3dc967 Mon Sep 17 00:00:00 2001 From: Claude Brisson Date: Thu, 24 Jul 2025 15:05:51 +0200 Subject: [PATCH] Use a PairingListener class to collect or print weights, avoid computing twice the weights during tests --- .../jeudego/pairgoth/api/PairingHandler.kt | 13 ++- .../org/jeudego/pairgoth/model/Pairing.kt | 12 ++- .../org/jeudego/pairgoth/model/Tournament.kt | 9 +- .../pairgoth/pairing/solver/MacMahonSolver.kt | 7 +- .../pairing/solver/PairingListener.kt | 75 +++++++++++++++++ .../jeudego/pairgoth/pairing/solver/Solver.kt | 82 +++++++++---------- api-webapp/src/test/kotlin/BOSP2024Test.kt | 16 ++-- api-webapp/src/test/kotlin/MalavasiTest.kt | 9 +- api-webapp/src/test/kotlin/PairingTests.kt | 46 +++++------ api-webapp/src/test/kotlin/TestUtils.kt | 32 +++++++- 10 files changed, 204 insertions(+), 97 deletions(-) create mode 100644 api-webapp/src/main/kotlin/org/jeudego/pairgoth/pairing/solver/PairingListener.kt diff --git a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/PairingHandler.kt b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/PairingHandler.kt index 50552ec..35d8a3e 100644 --- a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/PairingHandler.kt +++ b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/api/PairingHandler.kt @@ -13,7 +13,10 @@ import org.jeudego.pairgoth.model.Tournament import org.jeudego.pairgoth.model.getID import org.jeudego.pairgoth.model.toID import org.jeudego.pairgoth.model.toJson +import org.jeudego.pairgoth.pairing.solver.LoggingListener import org.jeudego.pairgoth.server.Event.* +import java.io.FileWriter +import java.io.PrintWriter import javax.servlet.http.HttpServletRequest import javax.servlet.http.HttpServletResponse @@ -67,7 +70,15 @@ object PairingHandler: PairgothApiHandler { if (playing.contains(it.id)) badRequest("pairable #$id already plays round $round") } ?: badRequest("invalid pairable id: #$id") } - val games = tournament.pair(round, pairables) + + // POST pair/$round accepts a few parameters to help tests + val legacy = request.getParameter("legacy")?.toBoolean() ?: false + val weightsLogger = request.getParameter("weights_output")?.let { + val append = request.getParameter("append")?.toBoolean() ?: false + LoggingListener(PrintWriter(FileWriter(it, append))) + } + + val games = tournament.pair(round, pairables, legacy, weightsLogger) val ret = games.map { it.toJson() }.toJsonArray() tournament.dispatchEvent(GamesAdded, request, Json.Object("round" to round, "games" to ret)) diff --git a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/model/Pairing.kt b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/model/Pairing.kt index 6d1ee68..f887c42 100644 --- a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/model/Pairing.kt +++ b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/model/Pairing.kt @@ -7,6 +7,7 @@ import org.jeudego.pairgoth.model.PairingType.* import org.jeudego.pairgoth.pairing.HistoryHelper import org.jeudego.pairgoth.pairing.solver.Solver import org.jeudego.pairgoth.pairing.solver.MacMahonSolver +import org.jeudego.pairgoth.pairing.solver.PairingListener import org.jeudego.pairgoth.pairing.solver.SwissSolver import kotlin.math.min @@ -133,8 +134,15 @@ sealed class Pairing( val placementParams: PlacementParams) { companion object {} abstract fun solver(tournament: Tournament<*>, round: Int, pairables: List): Solver - fun pair(tournament: Tournament<*>, round: Int, pairables: List): List { - return solver(tournament, round, pairables).pair() + fun pair(tournament: Tournament<*>, round: Int, pairables: List, legacyMode: Boolean = false, listener: PairingListener? = null): List { + return solver(tournament, round, pairables) + .also { solver -> + solver.legacyMode = legacyMode + listener?.let { + solver.pairingListener = listener + } + } + .pair() } } diff --git a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/model/Tournament.kt b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/model/Tournament.kt index a7bdaed..efee97e 100644 --- a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/model/Tournament.kt +++ b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/model/Tournament.kt @@ -9,6 +9,7 @@ import java.time.LocalDate import org.jeudego.pairgoth.api.ApiHandler.Companion.badRequest import org.jeudego.pairgoth.pairing.HistoryHelper import org.jeudego.pairgoth.pairing.solver.MacMahonSolver +import org.jeudego.pairgoth.pairing.solver.PairingListener import org.jeudego.pairgoth.store.nextGameId import org.jeudego.pairgoth.store.nextPlayerId import org.jeudego.pairgoth.store.nextTournamentId @@ -64,7 +65,7 @@ sealed class Tournament ( var frozen: Json.Array? = null // pairing - open fun pair(round: Int, pairables: List): List { + open fun pair(round: Int, pairables: List, legacyMode: Boolean = false, listener: PairingListener? = null): List { // Minimal check on round number. // CB TODO - the complete check should verify, for each player, that he was either non pairable or implied in the previous round if (round > games.size + 1) badRequest("previous round not paired") @@ -72,7 +73,7 @@ sealed class Tournament ( val evenPairables = if (pairables.size % 2 == 0) pairables else pairables.toMutableList().also { it.add(ByePlayer) } - return pairing.pair(this, round, evenPairables).also { newGames -> + return pairing.pair(this, round, evenPairables, legacyMode, listener).also { newGames -> if (games.size < round) games.add(mutableMapOf()) games[round - 1].putAll( newGames.associateBy { it.id } ) } @@ -291,8 +292,8 @@ class TeamTournament( } } - override fun pair(round: Int, pairables: List) = - super.pair(round, pairables).also { games -> + override fun pair(round: Int, pairables: List, legacyMode: Boolean, listener: PairingListener?) = + super.pair(round, pairables, legacyMode, listener).also { games -> if (type.individual) { games.forEach { game -> pairIndividualGames(round, game) diff --git a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/pairing/solver/MacMahonSolver.kt b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/pairing/solver/MacMahonSolver.kt index 7dba94b..8da997a 100644 --- a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/pairing/solver/MacMahonSolver.kt +++ b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/pairing/solver/MacMahonSolver.kt @@ -43,8 +43,7 @@ class MacMahonSolver(round: Int, return 2 * p.score } - override fun SecondaryCritParams.apply(p1: Pairable, p2: Pairable): Double { - + override fun SecondaryCritParams.playersMeetCriteria(p1: Pairable, p2: Pairable): Int { // playersMeetCriteria = 0 : No player is above thresholds -> apply the full weight // playersMeetCriteria = 1 : 1 player is above thresholds -> apply half the weight // playersMeetCriteria = 2 : Both players are above thresholds -> do not apply weight @@ -60,14 +59,14 @@ class MacMahonSolver(round: Int, if (2 * p1.nbW >= nbw2Threshold // check if STARTING MMS is above MM bar (OpenGotha v3.52 behavior) || barThresholdActive && (p1.mmBase >= mmBar - Pairable.MIN_RANK) - || p1.mms >= rankSecThreshold - Pairable.MIN_RANK) playersMeetCriteria++ + || p1.mms >= rankSecThreshold - Pairable.MIN_RANK) playersMeetCriteria++ if (2 * p2.nbW >= nbw2Threshold // check if STARTING MMS is above MM bar (OpenGotha v3.52 behavior) || barThresholdActive && (p2.mmBase >= mmBar - Pairable.MIN_RANK) || p2.mms >= rankSecThreshold - Pairable.MIN_RANK) playersMeetCriteria++ - return pairing.geo.apply(p1, p2, playersMeetCriteria) + return playersMeetCriteria } override fun HandicapParams.pseudoRank(pairable: Pairable): Int { diff --git a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/pairing/solver/PairingListener.kt b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/pairing/solver/PairingListener.kt new file mode 100644 index 0000000..8e5e429 --- /dev/null +++ b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/pairing/solver/PairingListener.kt @@ -0,0 +1,75 @@ +package org.jeudego.pairgoth.pairing.solver + +import org.jeudego.pairgoth.model.ID +import org.jeudego.pairgoth.model.Pairable +import org.jeudego.pairgoth.model.Tournament +import java.io.PrintWriter + +interface PairingListener { + + fun start(round: Int) {} + fun startPair(white: Pairable, black: Pairable) {} + fun endPair(white: Pairable, black: Pairable) {} + fun addWeight(name: String, weight: Double) + fun end() {} +} + +class LoggingListener(val out: PrintWriter) : PairingListener { + + var currentOpenGothaWeight: Double = 0.0 + + override fun start(round: Int) { + out.println("Round $round") + out.println("Costs") + } + + override fun startPair(white: Pairable, black: Pairable) { + currentOpenGothaWeight = 0.0 + out.println("Player1Name=${white.fullName()}") + out.println("Player2Name=${black.fullName()}") + } + + override fun addWeight(name: String, weight: Double) { + // Try hard to stay in sync with current reference files of OpenGotha conformance tests + val key = when (name) { + // TODO - Change to propagate to test reference files + "baseColorBalance" -> "baseBWBalance" + // Pairgoth-specific part of the color balance, not considered in conformance tests + "secColorBalance" -> return + else -> name + } + val value = when (name) { + // TODO - This cost is always zero in reference files, seems unused + "secHandi" -> 0.0 + else -> weight + } + currentOpenGothaWeight += value + out.println("${key}Cost=$value") + } + + override fun endPair(white: Pairable, black: Pairable) { + out.println("totalCost=$currentOpenGothaWeight") + } + + override fun end() { + out.flush() + } +} + +class CollectingListener() : PairingListener { + + val out = mutableMapOf, MutableMap>() + var white: Pairable? = null + var black: Pairable? = null + + override fun startPair(white: Pairable, black: Pairable) { + this.white = white + this.black = black + } + + override fun addWeight(name: String, weight: Double) { + val key = Pair(white!!.id, black!!.id) + val weights = out.computeIfAbsent(key) { mutableMapOf() } + weights[name] = weight + } +} diff --git a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/pairing/solver/Solver.kt b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/pairing/solver/Solver.kt index 85a6c6a..127929f 100644 --- a/api-webapp/src/main/kotlin/org/jeudego/pairgoth/pairing/solver/Solver.kt +++ b/api-webapp/src/main/kotlin/org/jeudego/pairgoth/pairing/solver/Solver.kt @@ -32,11 +32,12 @@ sealed class Solver( companion object { val rand = Random(/* seed from properties - TODO */) - // Used in tests - var weightsLogger: PrintWriter? = null - var legacy_mode = false } + // For tests and explain feature + var legacyMode = false + var pairingListener: PairingListener? = null + init { history.scoresFactory = this::mainScoreMapFactory history.scoresXFactory = this::scoreXMapFactory @@ -59,7 +60,7 @@ sealed class Solver( abstract fun missedRoundSosMapFactory(): Map open fun openGothaWeight(p1: Pairable, p2: Pairable) = - 1.0 + // 1 is minimum value because 0 means "no matching allowed" + 1.0 + // 1 is the minimum value because 0 means "no matching allowed" pairing.base.apply(p1, p2) + pairing.main.apply(p1, p2) + pairing.secondary.apply(p1, p2) @@ -71,13 +72,19 @@ sealed class Solver( else 0.0 } - open fun weight(p1: Pairable, p2: Pairable) = - openGothaWeight(p1, p2) + - pairgothBlackWhite(p1, p2) + - // pairing.base.applyByeWeight(p1, p2) + - pairing.handicap.color(p1, p2) + open fun weight(p1: Pairable, p2: Pairable): Double { + pairingListener?.startPair(p1, p2) + return ( + openGothaWeight(p1, p2) + + pairgothBlackWhite(p1, p2).also { pairingListener?.addWeight("secColorBalance", it) } + + // pairing.base.applyByeWeight(p1, p2) + + pairing.handicap.color(p1, p2).also { pairingListener?.addWeight("secHandi", it) } + ).also { + pairingListener?.endPair(p1, p2) + } + } - open fun computeWeightForBye(p: Pairable): Double{ + open fun computeWeightForBye(p: Pairable): Double { // The weightForBye function depends on the system type (Mac-Mahon or Swiss), default value is 0.0 return 0.0 } @@ -92,10 +99,7 @@ sealed class Solver( val logger = LoggerFactory.getLogger("debug") val debug = false - weightsLogger?.apply { - this.println("Round $round") - this.println("Costs") - } + pairingListener?.start(round) var chosenByePlayer: Pairable = ByePlayer // Choose bye player and remove from pairables @@ -124,21 +128,6 @@ sealed class Solver( val q = nameSortedPairables[j] weight(p, q).let { if (it != Double.NaN) builder.addEdge(p, q, it/1e6) } weight(q, p).let { if (it != Double.NaN) builder.addEdge(q, p, it/1e6) } - weightsLogger?.apply { - this.println("Player1Name=${p.fullName()}") - this.println("Player2Name=${q.fullName()}") - this.println("baseDuplicateGameCost=${dec.format(pairing.base.avoidDuplicatingGames(p, q))}") - this.println("baseRandomCost=${dec.format(pairing.base.applyRandom(p, q))}") - this.println("baseBWBalanceCost=${dec.format(pairing.base.applyColorBalance(p, q))}") - this.println("mainCategoryCost=${dec.format(pairing.main.avoidMixingCategory(p, q))}") - this.println("mainScoreDiffCost=${dec.format(pairing.main.minimizeScoreDifference(p, q))}") - this.println("mainDUDDCost=${dec.format(pairing.main.applyDUDD(p, q))}") - this.println("mainSeedCost=${dec.format(pairing.main.applySeeding(p, q))}") - this.println("secHandiCost=${dec.format(pairing.handicap.handicap(p, q))}") - this.println("secGeoCost=${dec.format(pairing.secondary.apply(p, q))}") - this.println("totalCost=${dec.format(openGothaWeight(p,q))}") - //File(WEIGHTS_FILE).appendText("ByeCost="+dec.format(pairing.base.applyByeWeight(p,q))+"\n") - } } } val graph = builder.build() @@ -153,6 +142,8 @@ sealed class Solver( // add game for ByePlayer if (chosenByePlayer != ByePlayer) result += Game(id = nextGameId, table = 0, white = chosenByePlayer.id, black = ByePlayer.id, result = Game.Result.fromSymbol('w')) + pairingListener?.end() + if (debug) { var sumOfWeights = 0.0 @@ -216,11 +207,11 @@ sealed class Solver( var score = 0.0 // Base Criterion 1 : Avoid Duplicating Game // Did p1 and p2 already play ? - score += avoidDuplicatingGames(p1, p2) + score += avoidDuplicatingGames(p1, p2).also { pairingListener?.addWeight("baseDuplicateGame", it) } // Base Criterion 2 : Random - score += applyRandom(p1, p2) + score += applyRandom(p1, p2).also { pairingListener?.addWeight("baseRandom", it) } // Base Criterion 3 : Balance W and B - score += applyColorBalance(p1, p2) + score += applyColorBalance(p1, p2).also { pairingListener?.addWeight("baseColorBalance", it) } return score } @@ -280,16 +271,16 @@ sealed class Solver( var score = 0.0 // Main criterion 1 avoid mixing category is moved to Swiss with category - score += avoidMixingCategory(p1, p2) + score += avoidMixingCategory(p1, p2).also { pairingListener?.addWeight("mainCategory", it) } // Main criterion 2 minimize score difference - score += minimizeScoreDifference(p1, p2) + score += minimizeScoreDifference(p1, p2).also { pairingListener?.addWeight("mainScoreDiff", it) } // Main criterion 3 If different groups, make a directed Draw-up/Draw-down - score += applyDUDD(p1, p2) + score += applyDUDD(p1, p2).also { pairingListener?.addWeight("mainDUDD", it) } // Main criterion 4 seeding - score += applySeeding(p1, p2) + score += applySeeding(p1, p2).also { pairingListener?.addWeight("mainSeed", it) } return score } @@ -411,7 +402,7 @@ sealed class Solver( val randRange = maxSeedingWeight * 0.2 // for old tests to pass val rand = - if (legacy_mode && p1.fullName() > p2.fullName()) { + if (legacyMode && p1.fullName() > p2.fullName()) { // for old tests to pass detRandom(randRange, p2, p1, false) } else { @@ -427,8 +418,7 @@ sealed class Solver( return Math.round(score).toDouble() } - open fun SecondaryCritParams.apply(p1: Pairable, p2: Pairable): Double { - + open fun SecondaryCritParams.playersMeetCriteria(p1: Pairable, p2: Pairable): Int { // playersMeetCriteria = 0 : No player is above thresholds -> apply secondary criteria // playersMeetCriteria = 1 : 1 player is above thresholds -> apply half the weight // playersMeetCriteria = 2 : Both players are above thresholds -> apply the full weight @@ -441,7 +431,11 @@ sealed class Solver( if (2*p1.nbW >= nbw2Threshold) playersMeetCriteria++ if (2*p2.nbW >= nbw2Threshold) playersMeetCriteria++ - return pairing.geo.apply(p1, p2, playersMeetCriteria) + return playersMeetCriteria + } + + fun SecondaryCritParams.apply(p1: Pairable, p2: Pairable): Double { + return pairing.geo.apply(p1, p2, playersMeetCriteria(p1, p2)) } fun GeographicalParams.apply(p1: Pairable, p2: Pairable, playersMeetCriteria: Int): Double { @@ -449,11 +443,11 @@ sealed class Solver( val geoMaxCost = pairing.geo.avoidSameGeo - val countryFactor: Int = if (legacy_mode || biggestCountrySize.toDouble() / pairables.size <= proportionMainClubThreshold) + val countryFactor: Int = if (legacyMode || biggestCountrySize.toDouble() / pairables.size <= proportionMainClubThreshold) preferMMSDiffRatherThanSameCountry else 0 - val clubFactor: Int = if (legacy_mode || biggestClubSize.toDouble() / pairables.size <= proportionMainClubThreshold) + val clubFactor: Int = if (legacyMode || biggestClubSize.toDouble() / pairables.size <= proportionMainClubThreshold) preferMMSDiffRatherThanSameClub else 0 @@ -511,7 +505,7 @@ sealed class Solver( 2 -> geoMaxCost 1 -> 0.5 * (geoNominalCost + geoMaxCost) else -> geoNominalCost - } + }.also { pairingListener?.addWeight("secGeo", it) } } // Handicap functions @@ -559,7 +553,7 @@ sealed class Solver( } else if (p1.colorBalance < p2.colorBalance) { score = 1.0 } else { // choose color from a det random - if (detRandom(1.0, p1, p2, false) === 0.0) { + if (detRandom(1.0, p1, p2, false) == 0.0) { score = 1.0 } else { score = -1.0 diff --git a/api-webapp/src/test/kotlin/BOSP2024Test.kt b/api-webapp/src/test/kotlin/BOSP2024Test.kt index 50970c9..b828818 100644 --- a/api-webapp/src/test/kotlin/BOSP2024Test.kt +++ b/api-webapp/src/test/kotlin/BOSP2024Test.kt @@ -20,24 +20,22 @@ class BOSP2024Test: TestBase() { )!!.asObject() val resp = TestAPI.post("/api/tour", tournament).asObject() val tourId = resp.getInt("id") - Solver.weightsLogger = PrintWriter(FileWriter(getOutputFile("bosp2024-weights.txt"))) - - Solver.legacy_mode = true - TestAPI.post("/api/tour/$tourId/pair/3", Json.Array("all")).asArray() + val outputFile = getOutputFile("bosp2024-weights.txt") + TestAPI.post("/api/tour/$tourId/pair/3?legacy=true&weights_output=$outputFile", Json.Array("all")).asArray() // compare weights - assertTrue(compare_weights(getOutputFile("bosp2024-weights.txt"), getTestFile("opengotha/bosp2024/bosp2024_weights_R3.txt")), "Not matching opengotha weights for BOSP test") + assertTrue(compare_weights(outputFile, getTestFile("opengotha/bosp2024/bosp2024_weights_R3.txt")), "Not matching opengotha weights for BOSP test") TestAPI.delete("/api/tour/$tourId/pair/3", Json.Array("all")) - Solver.legacy_mode = false val games = TestAPI.post("/api/tour/$tourId/pair/3", Json.Array("all")).asArray() // Aksut Husrev is ID 18 - val solved = games.map { it as Json.Object }.filter { game -> + val solved = games.map { it as Json.Object }.firstOrNull { game -> // build the two-elements set of players ids - val players = game.entries.filter { (k, v) -> k == "b" || k == "w" }.map { (k, v) -> (v as Number).toInt() }.toSet() + val players = + game.entries.filter { (k, v) -> k == "b" || k == "w" }.map { (k, v) -> (v as Number).toInt() }.toSet() // keep game with Aksut Husrev players.contains(18) - }.firstOrNull() + } assertNotNull(solved) diff --git a/api-webapp/src/test/kotlin/MalavasiTest.kt b/api-webapp/src/test/kotlin/MalavasiTest.kt index 61c1bba..9d5f58d 100644 --- a/api-webapp/src/test/kotlin/MalavasiTest.kt +++ b/api-webapp/src/test/kotlin/MalavasiTest.kt @@ -1,11 +1,8 @@ package org.jeudego.pairgoth.test import com.republicate.kson.Json -import org.jeudego.pairgoth.pairing.solver.Solver import org.jeudego.pairgoth.test.PairingTests.Companion.compare_weights import org.junit.jupiter.api.Test -import java.io.FileWriter -import java.io.PrintWriter import java.nio.charset.StandardCharsets import kotlin.test.assertEquals import kotlin.test.assertTrue @@ -19,8 +16,8 @@ class MalavasiTest: TestBase() { )!!.asObject() val resp = TestAPI.post("/api/tour", tournament).asObject() val tourId = resp.getInt("id") - Solver.weightsLogger = PrintWriter(FileWriter(getOutputFile("malavasi-weights.txt"))) - val games = TestAPI.post("/api/tour/$tourId/pair/2", Json.Array("all")).asArray() + val outputFile = getOutputFile("malavasi-weights.txt") + val games = TestAPI.post("/api/tour/$tourId/pair/2?weights_output=$outputFile", Json.Array("all")).asArray() // Oceane is ID 548, Valentine 549 val buggy = games.map { it as Json.Object }.filter { game -> // build the two-elements set of players ids @@ -33,6 +30,6 @@ class MalavasiTest: TestBase() { assertEquals(2, buggy.size) // compare weights - assertTrue(compare_weights(getOutputFile("malavasi-weights.txt"), getTestFile("opengotha/malavasi/malavasi_weights_R2.txt")), "Not matching opengotha weights for Malavasi test") + assertTrue(compare_weights(outputFile, getTestFile("opengotha/malavasi/malavasi_weights_R2.txt")), "Not matching opengotha weights for Malavasi test") } } diff --git a/api-webapp/src/test/kotlin/PairingTests.kt b/api-webapp/src/test/kotlin/PairingTests.kt index 9f1160e..8ca4c1f 100644 --- a/api-webapp/src/test/kotlin/PairingTests.kt +++ b/api-webapp/src/test/kotlin/PairingTests.kt @@ -8,8 +8,6 @@ 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 @@ -59,7 +57,6 @@ class PairingTests: TestBase() { } fun compare_weights(file1: File, file2: File, skipSeeding: Boolean = false):Boolean { - Solver.weightsLogger!!.flush() // Maps to store name pairs and costs val map1 = create_weights_map(file1) val map2 = create_weights_map(file2) @@ -165,6 +162,7 @@ class PairingTests: TestBase() { } fun test_from_XML(name: String, forcePairing:List) { + // Let pairgoth use the legacy asymmetric detRandom() 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) @@ -172,11 +170,10 @@ class PairingTests: TestBase() { fun test_from_XML_internal(name: String, forcePairing:List, legacy: Boolean) { // Let pairgoth use the legacy asymmetric detRandom() - Solver.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) + val tourFile = getTestFile("opengotha/pairings/$name.xml") + logger.info("read from file $tourFile") + val resource = tourFile.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() @@ -203,15 +200,15 @@ class PairingTests: TestBase() { for (round in 1..tournament.getInt("rounds")!!) { val sumOfWeightsOG = compute_sumOfWeight_OG(getTestFile("opengotha/$name/$name" + "_weights_R$round.txt"), pairingsOG[round-1], players) - Solver.weightsLogger = PrintWriter(FileWriter(getOutputFile("weights.txt"))) + val outputFile = getOutputFile("weights.txt") // Call Pairgoth pairing solver to generate games - games = TestAPI.post("/api/tour/$id/pair/$round", Json.Array("all")).asArray() + games = TestAPI.post("/api/tour/$id/pair/$round?legacy=$legacy&weights_output=$outputFile", 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") + assertTrue(compare_weights(outputFile, getTestFile("opengotha/$name/$name"+"_weights_R$round.txt")), "Not matching opengotha weights for round $round") } if (round in forcePairing) { @@ -223,7 +220,7 @@ class PairingTests: TestBase() { 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() + TestAPI.put("/api/tour/$id/pair/$round?legacy=$legacy&weights_output=$outputFile&append=true", Json.parse("""{"id":$gameID,"w":$whiteId,"b":$blackId}""")).asObject() } games = TestAPI.get("/api/tour/$id/res/$round").asArray() } @@ -273,11 +270,10 @@ class PairingTests: TestBase() { @Test fun `SwissTest simpleSwiss`() { - Solver.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 tourFile = getTestFile("opengotha/pairings/simpleswiss.xml") + logger.info("read from file $tourFile") + val resource = tourFile.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() @@ -315,10 +311,10 @@ class PairingTests: TestBase() { var firstGameID: Int for (round in 1..7) { - Solver.weightsLogger = PrintWriter(FileWriter(getOutputFile("weights.txt"))) - games = TestAPI.post("/api/tour/$id/pair/$round", Json.Array("all")).asArray() + val outputFile = getOutputFile("weights.txt") + games = TestAPI.post("/api/tour/$id/pair/$round?legacy=true&weights_output=$outputFile", 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_weights(outputFile, 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") @@ -354,12 +350,12 @@ class PairingTests: TestBase() { @Test fun `SwissTest KPMCSplitbug`() { // Let pairgoth use the legacy asymmetric detRandom() - Solver.legacy_mode = true + val legacy = 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) + val tourFile = getTestFile("opengotha/pairings/$name.xml") + logger.info("read from file $tourFile") + val resource = tourFile.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() @@ -387,13 +383,13 @@ class PairingTests: TestBase() { var games: Json.Array var firstGameID: Int + val outputFile = getOutputFile("weights.txt") for (round in minRound..maxRound) { val sumOfWeightsOG = compute_sumOfWeight_OG(getTestFile("opengotha/$name/$name" + "_weights_R$round.txt"), pairingsOG[round - minRound], players) - Solver.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() + games = TestAPI.post("/api/tour/$id/pair/$round?legacy=$legacy&weights_ouput=$outputFile&append=${round > 1}", Json.Array("all")).asArray() logger.info("sumOfWeightOG = " + dec.format(sumOfWeightsOG)) logger.info("games for round $round: {}", games.toString().slice(0..50) + "...") @@ -401,7 +397,7 @@ class PairingTests: TestBase() { // Compare weights with OpenGotha assertTrue( compare_weights( - getOutputFile("weights.txt"), + outputFile, getTestFile("opengotha/$name/$name" + "_weights_R$round.txt") ), "Not matching opengotha weights for round $round" ) diff --git a/api-webapp/src/test/kotlin/TestUtils.kt b/api-webapp/src/test/kotlin/TestUtils.kt index 56bae48..05e05f5 100644 --- a/api-webapp/src/test/kotlin/TestUtils.kt +++ b/api-webapp/src/test/kotlin/TestUtils.kt @@ -7,6 +7,8 @@ import org.jeudego.pairgoth.server.SSEServlet import org.jeudego.pairgoth.server.WebappManager import org.mockito.kotlin.* import java.io.* +import java.net.URL +import java.net.URLDecoder import java.nio.charset.StandardCharsets import java.util.* import javax.servlet.ReadListener @@ -21,20 +23,45 @@ object TestAPI { fun Any?.toUnit() = Unit + fun parseURL(url: String): Pair> { + val qm = url.indexOf('?') + if (qm == -1) { + return url to emptyMap() + } + val uri = url.substring(0, qm) + val params = url.substring(qm + 1) + .split('&') + .map { it.split('=') } + .mapNotNull { + when (it.size) { + 1 -> it[0].decodeUTF8() to "" + 2 -> it[0].decodeUTF8() to it[1].decodeUTF8() + else -> null + } + } + .toMap() + return uri to params + } + + private fun String.decodeUTF8() = URLDecoder.decode(this, "UTF-8") // decode page=%22ABC%22 to page="ABC" + private val apiServlet = ApiServlet() private val sseServlet = SSEServlet() - private fun testRequest(reqMethod: String, uri: String, accept: String = "application/json", payload: T? = null): String { + private fun testRequest(reqMethod: String, url: String, accept: String = "application/json", payload: T? = null): String { WebappManager.properties["auth"] = "none" WebappManager.properties["store"] = "memory" WebappManager.properties["webapp.env"] = "test" + val (uri, parameters) = parseURL(url) + // mock request val myHeaderNames = if (reqMethod == "GET") emptyList() else listOf("Content-Type") val selector = argumentCaptor() val subSelector = argumentCaptor() val reqPayload = argumentCaptor() + val parameter = argumentCaptor() val myInputStream = payload?.let { DelegatingServletInputStream(payload.toString().byteInputStream(StandardCharsets.UTF_8)) } val myReader = payload?.let { BufferedReader(StringReader(payload.toString())) } val req = mock { @@ -59,6 +86,7 @@ object TestAPI { } on { headerNames } doReturn Collections.enumeration(myHeaderNames) on { getHeader(eq("Accept")) } doReturn accept + on { getParameter(parameter.capture()) } doAnswer { parameters[parameter.lastValue] } } // mock response @@ -77,7 +105,7 @@ object TestAPI { "DELETE" -> apiServlet.doDelete(req, resp) } - return buffer.toString() ?: throw Error("no response payload") + return buffer.toString() } fun get(uri: String): Json = Json.parse(testRequest("GET", uri)) ?: throw Error("no payload")