Use a PairingListener class to collect or print weights, avoid computing twice the weights during tests

This commit is contained in:
Claude Brisson
2025-07-24 15:05:51 +02:00
parent f704f3adb2
commit 3d06588889
10 changed files with 204 additions and 97 deletions

View File

@@ -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))

View File

@@ -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<Pairable>): Solver
fun pair(tournament: Tournament<*>, round: Int, pairables: List<Pairable>): List<Game> {
return solver(tournament, round, pairables).pair()
fun pair(tournament: Tournament<*>, round: Int, pairables: List<Pairable>, legacyMode: Boolean = false, listener: PairingListener? = null): List<Game> {
return solver(tournament, round, pairables)
.also { solver ->
solver.legacyMode = legacyMode
listener?.let {
solver.pairingListener = listener
}
}
.pair()
}
}

View File

@@ -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 <P: Pairable>(
var frozen: Json.Array? = null
// pairing
open fun pair(round: Int, pairables: List<Pairable>): List<Game> {
open fun pair(round: Int, pairables: List<Pairable>, legacyMode: Boolean = false, listener: PairingListener? = null): List<Game> {
// 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 <P: Pairable>(
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<Pairable>) =
super.pair(round, pairables).also { games ->
override fun pair(round: Int, pairables: List<Pairable>, legacyMode: Boolean, listener: PairingListener?) =
super.pair(round, pairables, legacyMode, listener).also { games ->
if (type.individual) {
games.forEach { game ->
pairIndividualGames(round, game)

View File

@@ -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
@@ -67,7 +66,7 @@ class MacMahonSolver(round: Int,
|| 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 {

View File

@@ -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<Pair<ID, ID>, MutableMap<String, Double>>()
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
}
}

View File

@@ -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<ID, Double>
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) =
open fun weight(p1: Pairable, p2: Pairable): Double {
pairingListener?.startPair(p1, p2)
return (
openGothaWeight(p1, p2) +
pairgothBlackWhite(p1, p2) +
pairgothBlackWhite(p1, p2).also { pairingListener?.addWeight("secColorBalance", it) } +
// pairing.base.applyByeWeight(p1, p2) +
pairing.handicap.color(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

View File

@@ -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)

View File

@@ -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")
}
}

View File

@@ -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<Int>) {
// 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<Int>, 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"
)

View File

@@ -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<String, Map<String, String>> {
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 <T> testRequest(reqMethod: String, uri: String, accept: String = "application/json", payload: T? = null): String {
private fun <T> 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<String>()
val subSelector = argumentCaptor<String>()
val reqPayload = argumentCaptor<String>()
val parameter = argumentCaptor<String>()
val myInputStream = payload?.let { DelegatingServletInputStream(payload.toString().byteInputStream(StandardCharsets.UTF_8)) }
val myReader = payload?.let { BufferedReader(StringReader(payload.toString())) }
val req = mock<HttpServletRequest> {
@@ -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<Void>("GET", uri)) ?: throw Error("no payload")