From ba977880ede0c2c50f14c0ab5f11eb0259e2d342 Mon Sep 17 00:00:00 2001 From: Rene Luria Date: Fri, 28 Aug 2026 15:01:22 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20r=C3=A9sultats=20de=20partie,=20enregis?= =?UTF-8?q?trements=20de=20r=C3=A9ponses=20et=20stats=20par=20table?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/com/livrets/game/AnswerRecord.kt | 12 +++ .../kotlin/com/livrets/game/GameResult.kt | 24 +++++ .../kotlin/com/livrets/game/TableStats.kt | 19 ++++ .../kotlin/com/livrets/game/GameResultTest.kt | 88 +++++++++++++++++++ 4 files changed, 143 insertions(+) create mode 100644 sharedUI/src/commonMain/kotlin/com/livrets/game/AnswerRecord.kt create mode 100644 sharedUI/src/commonMain/kotlin/com/livrets/game/GameResult.kt create mode 100644 sharedUI/src/commonMain/kotlin/com/livrets/game/TableStats.kt create mode 100644 sharedUI/src/commonTest/kotlin/com/livrets/game/GameResultTest.kt diff --git a/sharedUI/src/commonMain/kotlin/com/livrets/game/AnswerRecord.kt b/sharedUI/src/commonMain/kotlin/com/livrets/game/AnswerRecord.kt new file mode 100644 index 0000000..f5e7ff3 --- /dev/null +++ b/sharedUI/src/commonMain/kotlin/com/livrets/game/AnswerRecord.kt @@ -0,0 +1,12 @@ +package com.livrets.game + +import kotlinx.serialization.Serializable + +@Serializable +data class AnswerRecord( + val question: Question, + val givenAnswer: Int?, + val correctAnswer: Int, + val isCorrect: Boolean, + val durationMs: Long, +) diff --git a/sharedUI/src/commonMain/kotlin/com/livrets/game/GameResult.kt b/sharedUI/src/commonMain/kotlin/com/livrets/game/GameResult.kt new file mode 100644 index 0000000..773d61d --- /dev/null +++ b/sharedUI/src/commonMain/kotlin/com/livrets/game/GameResult.kt @@ -0,0 +1,24 @@ +package com.livrets.game + +import kotlin.time.Instant +import kotlinx.serialization.Serializable + +@Serializable +data class GameResult( + val date: Instant, + val config: GameConfig, + val records: List, +) { + val correctCount: Int get() = records.count { it.isCorrect } + + val successRate: Double + get() = if (records.isEmpty()) 0.0 else correctCount.toDouble() / records.size + + val averageResponseMs: Long + get() = records.filter { it.givenAnswer != null } + .takeIf { it.isNotEmpty() } + ?.map { it.durationMs } + ?.average() + ?.toLong() + ?: 0L +} diff --git a/sharedUI/src/commonMain/kotlin/com/livrets/game/TableStats.kt b/sharedUI/src/commonMain/kotlin/com/livrets/game/TableStats.kt new file mode 100644 index 0000000..1e9ad44 --- /dev/null +++ b/sharedUI/src/commonMain/kotlin/com/livrets/game/TableStats.kt @@ -0,0 +1,19 @@ +package com.livrets.game + +data class TableStats( + val table: Int, + val attempts: Int, + val correctCount: Int, +) { + val successRate: Double get() = if (attempts == 0) 0.0 else correctCount.toDouble() / attempts +} + +fun List.tableStats(): List = + (1..12).map { table -> + val records = flatMap { it.records }.filter { it.question.table == table } + TableStats( + table = table, + attempts = records.size, + correctCount = records.count { it.isCorrect }, + ) + } diff --git a/sharedUI/src/commonTest/kotlin/com/livrets/game/GameResultTest.kt b/sharedUI/src/commonTest/kotlin/com/livrets/game/GameResultTest.kt new file mode 100644 index 0000000..5271874 --- /dev/null +++ b/sharedUI/src/commonTest/kotlin/com/livrets/game/GameResultTest.kt @@ -0,0 +1,88 @@ +package com.livrets.game + +import kotlin.time.Instant +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class GameResultTest { + + private fun record(table: Int, correct: Boolean) = AnswerRecord( + question = Question.Direct(table, table), + givenAnswer = if (correct) table * table else table * table + 1, + correctAnswer = table * table, + isCorrect = correct, + durationMs = 1000, + ) + + @Test + fun `stats par table avec tables sans donnees`() { + val results = listOf( + GameResult( + date = Instant.parse("2026-01-01T00:00:00Z"), + config = GameConfig(4, 10, 12), + records = listOf( + record(7, true), record(7, true), record(7, false), record(9, false), + ), + ), + ) + val stats = results.tableStats() + assertEquals(12, stats.size) + assertEquals(7, stats[6].table) + assertEquals(3, stats[6].attempts) + assertEquals(2, stats[6].correctCount) + assertEquals(1, stats[8].attempts) + assertEquals(0, stats[3].attempts) + assertEquals(0.0, stats[3].successRate) + } + + @Test + fun `derivees de GameResult`() { + val result = GameResult( + date = Instant.parse("2026-01-01T00:00:00Z"), + config = GameConfig(3, 10, 12), + records = listOf( + record(2, true), + record(3, false), + AnswerRecord(Question.MissingFactor(4, 6), null, 6, false, 5000), + ), + ) + assertEquals(1, result.correctCount) + assertEquals(1.0 / 3, result.successRate, 1e-9) + assertTrue(result.averageResponseMs > 0) // seules les réponses non-nulles comptent + } + + @Test + fun `seuil table faible - en dessous de 60 pourcent`() { + val stats = listOf( + GameResult( + date = Instant.parse("2026-01-01T00:00:00Z"), + config = GameConfig(2, 10, 12), + records = listOf(record(8, false), record(8, false)), + ), + ).tableStats() + assertTrue(stats[7].successRate < 0.6) + } + + @Test + fun `serialisation aller-retour de GameResult`() { + val result = GameResult( + date = Instant.parse("2026-08-28T10:00:00Z"), + config = GameConfig(10, 10, 6), + records = listOf( + record(7, true), + AnswerRecord( + question = Question.MissingFactor(3, 8), + givenAnswer = null, + correctAnswer = 8, + isCorrect = false, + durationMs = 10_000, + ), + ), + ) + val json = Json.encodeToString(GameResult.serializer(), result) + val decoded = Json.decodeFromString(GameResult.serializer(), json) + assertEquals(result, decoded) + } +}