// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // RefonteTests.kt — tests de la refonte v2 : clustering par grille (miroir // iOS), historique consulté (dédupliqué, plafond 30), alertes honnêtes // (markSeen), galerie multi-photos, garde Trouve-Ka (q obligatoire). package com.groupeka.ka import com.groupeka.ka.core.ClusterEngine import com.groupeka.ka.core.Ecosystem import com.groupeka.ka.core.KAItem import com.groupeka.ka.core.RecentsStore import com.groupeka.ka.core.SavedSearch import com.groupeka.ka.core.UniverseService import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import org.junit.Assert.assertEquals import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test import java.io.File class RefonteTests { private fun item(id: String, lat: Double, lon: Double) = KAItem(id = id, universeID = "lou-ka", title = id, latitude = lat, longitude = lon) // MARK: clustering (miroir des 2 tests iOS) @Test fun clusteringGroupsNearbyAndKeepsSingles() { val items = listOf( item("a", 46.805, -71.205), item("b", 46.806, -71.206), // même cellule que a item("c", 46.90, -71.40), // loin → seul ) val clusters = ClusterEngine.clusterize(items, latSpan = 0.3, lonSpan = 0.3) assertEquals(2, clusters.size) val group = clusters.first { it.items.size == 2 } assertTrue(group.items.map { it.id }.containsAll(listOf("a", "b"))) assertTrue(clusters.any { it.isSingle && it.item.id == "c" }) // centroïde du groupe assertEquals(46.8055, group.latitude, 0.001) } @Test fun clusteringCapsAt150Renderables() { val many = (0 until 400).map { i -> item("i$i", 40.0 + i * 0.5, -70.0 - i * 0.5) // tous éloignés → 400 cellules } val clusters = ClusterEngine.clusterize(many, latSpan = 250.0, lonSpan = 250.0) assertTrue("plafond de rendu 150", clusters.size <= 150) } @Test fun clusteringIgnoresItemsWithoutCoordinates() { val items = listOf( KAItem(id = "x", universeID = "lou-ka", title = "sans coord"), item("y", 46.8, -71.2), ) val clusters = ClusterEngine.clusterize(items, latSpan = 0.3, lonSpan = 0.3) assertEquals(1, clusters.size) assertEquals("y", clusters[0].item.id) } // MARK: historique consulté @Test fun historyDeduplicatesAndCapsAt30() { val file = File.createTempFile("ka-recents", ".json").also { it.delete() } val store = RecentsStore(file) for (i in 0 until 40) { store.record(KAItem(id = "it$i", universeID = "lou-ka", title = "It $i")) } // re-consulter un ancien → remonte en tête, sans doublon store.record(KAItem(id = "it35", universeID = "lou-ka", title = "It 35")) val viewed = store.viewed.value assertEquals(30, viewed.size) assertEquals("it35", viewed.first().id) assertEquals(1, viewed.count { it.id == "it35" }) file.delete() } @Test fun historyPersistsAcrossInstances() { val file = File.createTempFile("ka-recents", ".json").also { it.delete() } RecentsStore(file).record(KAItem(id = "p:1", universeID = "auto-ka", title = "Corolla")) val reloaded = RecentsStore(file) assertEquals("p:1", reloaded.viewed.value.first().id) file.delete() } // MARK: alertes honnêtes @Test fun markSeenFoldsDeltaIntoTotal() { val file = File.createTempFile("ka-recents", ".json").also { it.delete() } // injecter une recherche « déjà comptée » via la persistance file.writeText( """{"viewed":[],"searches":[{"id":"s1","name":"4½ Québec","universeID":"lou-ka","query":"4½","alertsOn":true,"lastTotal":100,"newCount":12}]}""", ) val store2 = RecentsStore(file) assertEquals(12, store2.alertCount) store2.markSeen("s1") val s = store2.savedSearches.value.first() assertEquals(112, s.lastTotal) assertEquals(0, s.newCount) assertEquals(0, store2.alertCount) file.delete() } @Test fun alertCountSumsOnlyEnabledAlerts() { val file = File.createTempFile("ka-recents", ".json").also { it.delete() } file.writeText( """{"viewed":[],"searches":[ {"id":"a","name":"A","universeID":"lou-ka","query":"a","alertsOn":true,"newCount":5}, {"id":"b","name":"B","universeID":"auto-ka","query":"b","alertsOn":false,"newCount":9} ]}""", ) val store = RecentsStore(file) assertEquals(5, store.alertCount) file.delete() } // MARK: galerie multi-photos @Test fun galleryKeepsUpTo10HttpImages() { val u = Ecosystem.universe("auto-ka")!! val imgs = (1..14).joinToString(",") { "\"https://cdn.x.com/$it.jpg\"" } val o = Json.parseToJsonElement( """{"uid":"v:9","title":"Mazda 3","images":[$imgs,"pas-une-url"]}""", ) as JsonObject val item = u.map!!(o)!! assertEquals(10, item.imageURLs.size) assertEquals("https://cdn.x.com/1.jpg", item.imageURL) assertEquals(item.imageURL, item.imageURLs.first()) } // MARK: Trouve-Ka — recherche d'abord (l'API exige q, 422 sinon) @Test fun trouveKaFetchWithoutQueryReturnsEmptyWithoutNetwork() = runBlocking { val u = Ecosystem.universe("trouve-ka")!! // pas de cacheDir ni de réseau nécessaire : la garde répond avant val items = UniverseService.fetch(u, query = null) assertTrue(items.isEmpty()) assertNull(UniverseService.total(u, query = "")) } // MARK: éditorial = requêtes réelles vers de vrais univers @Test fun editorialCollectionsPointToRealUniverses() { val cols = com.groupeka.ka.core.Editorial.collections assertEquals(4, cols.size) cols.forEach { c -> assertTrue("univers inconnu ${c.universeID}", Ecosystem.universe(c.universeID) != null) } } }