Swift 100%
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2// KATests.swift — tests des parcours critiques hors réseau : mapping JSON →3// KAItem pour chaque adaptateur d'univers (échantillons réels des API),4// construction des URL de liste, favoris/collections.5import XCTest6import UIKit7@testable import KA89final class AdapterTests: XCTestCase {1011 private func decode(_ json: String) -> [String: JSONValue] {12 let v = try! JSONDecoder().decode(JSONValue.self, from: Data(json.utf8))13 return v.object!14 }1516 func testLouKaMapping() {17 let u = Ecosystem.universe("lou-ka")!18 let o = decode(#"{"uid":"a:1","title":"4½ Limoilou","city":"Québec","unit_type":"4½","price":1200,"url":"https://x.com/a","lat":46.8,"lng":-71.2,"address":"12 rue X"}"#)19 let item = u.map!(o)!20 XCTAssertEqual(item.universeID, "lou-ka")21 XCTAssertEqual(item.title, "4½ Limoilou")22 XCTAssertEqual(item.priceLabel?.contains("1"), true)23 XCTAssertEqual(item.city, "Québec")24 XCTAssertNotNil(item.latitude)25 XCTAssertTrue(item.facts.contains { $0.label == "Taille" && $0.value == "4½" })26 }2728 func testAutoKaMapping() {29 let u = Ecosystem.universe("auto-ka")!30 let o = decode(#"{"uid":"v:1","title":"Kia Forte 2010","make":"Kia","model":"Forte","year":2010,"price":1595,"price_label":"1 595 $","mileage_label":"260 789 km","url":"https://x.com/v"}"#)31 let item = u.map!(o)!32 XCTAssertEqual(item.priceLabel, "1 595 $")33 XCTAssertTrue(item.subtitle?.contains("2010") == true)34 XCTAssertTrue(item.facts.contains { $0.label == "Marque" && $0.value == "Kia" })35 }3637 func testJobKaSalary() {38 let u = Ecosystem.universe("job-ka")!39 let o = decode(#"{"uid":"j:1","title":"Analyste","employer":"TD","city":"Montréal","salary_year_min":65000}"#)40 let item = u.map!(o)!41 XCTAssertTrue(item.priceLabel?.contains("/an") == true)42 XCTAssertTrue(item.subtitle?.contains("TD") == true)43 }4445 func testTrouveKaStripsHTML() {46 let u = Ecosystem.universe("trouve-ka")!47 let o = decode(#"{"url":"https://y.qc","title":"La <em>poutine</em>","snippet":"un plat & une légende","domain":"y.qc"}"#)48 let item = u.map!(o)!49 XCTAssertEqual(item.title, "La poutine")50 XCTAssertEqual(item.subtitle, "un plat & une légende")51 }5253 func testFoodKaIgnoresPlaceholderPrice() {54 let u = Ecosystem.universe("food-ka")!55 let o = decode(#"{"uid":"p:1","name":"Prunes","price":0.01,"brand":"PA"}"#)56 let item = u.map!(o)!57 XCTAssertNil(item.priceLabel, "un prix placeholder (0,01 $) ne doit pas s'afficher")58 }5960 func testEveryUniverseHasDistinctAccent() {61 let accents = Ecosystem.all.map(\.accentHex)62 XCTAssertEqual(accents.count, Set(accents).count, "chaque univers doit avoir un accent unique")63 // 12 univers — Groupe-KA (portail mère) est l'app elle-même, pas un univers listé64 XCTAssertEqual(Ecosystem.all.count, 12)65 }6667 func testListURLBuilding() {68 let sorti = Ecosystem.universe("sorti-ka")!69 let url = UniverseService.listURL(sorti, query: "jazz", limit: 10)!70 let s = url.absoluteString71 XCTAssertTrue(s.contains("upcoming=true"), "la query embarquée du listPath doit survivre")72 XCTAssertTrue(s.contains("q=jazz"))73 XCTAssertTrue(s.contains("limit=10"))74 XCTAssertTrue(s.hasPrefix("https://www.sorti-ka.com/api/events"))75 }7677 @MainActor78 func testFavoritesToggleAndCollections() {79 let store = FavoritesStore()80 let item = KAItem(id: "t:1", universeID: "lou-ka", title: "Test",81 subtitle: nil, priceLabel: nil, city: nil, url: nil,82 imageURL: nil, latitude: nil, longitude: nil, detail: nil, facts: [])83 XCTAssertFalse(store.isFavorite(item))84 store.toggle(item)85 XCTAssertTrue(store.isFavorite(item))86 store.addCollection("Week-end")87 let dest = store.collections.last!88 store.move(item, to: dest.id)89 XCTAssertTrue(store.collections.last!.items.contains { $0.id == item.id })90 store.toggle(item)91 XCTAssertFalse(store.isFavorite(item))92 }93}9495// MARK: - Refonte : clustering carte + historique/recherches sauvegardées9697import MapKit9899final class RefonteTests: XCTestCase {100 private func item(_ id: String, lat: Double, lon: Double) -> KAItem {101 KAItem(id: id, universeID: "lou-ka", title: id, subtitle: nil, priceLabel: nil,102 city: nil, url: nil, imageURL: nil, latitude: lat, longitude: lon,103 detail: nil, facts: [])104 }105106 func testClusteringGroupsNearbyAndKeepsSingles() {107 let region = MKCoordinateRegion(center: .init(latitude: 46.8, longitude: -71.2),108 span: .init(latitudeDelta: 0.5, longitudeDelta: 0.5))109 // 3 points quasi identiques + 1 isolé110 let items = [111 item("a", lat: 46.800, lon: -71.200),112 item("b", lat: 46.801, lon: -71.201),113 item("c", lat: 46.8005, lon: -71.2005),114 item("z", lat: 46.95, lon: -71.05),115 ]116 let clusters = ClusterEngine.clusterize(items, region: region)117 XCTAssertEqual(clusters.count, 2)118 XCTAssertEqual(clusters.first?.items.count, 3, "les voisins doivent se regrouper")119 XCTAssertTrue(clusters.contains { $0.isSingle && $0.item.id == "z" })120 }121122 func testClusteringCapsRenderCount() {123 let region = MKCoordinateRegion(center: .init(latitude: 46, longitude: -71),124 span: .init(latitudeDelta: 10, longitudeDelta: 10))125 let many = (0..<2000).map { i in126 item("i\(i)", lat: 41 + Double(i % 500) * 0.02, lon: -76 + Double(i / 500) * 0.02)127 }128 let clusters = ClusterEngine.clusterize(many, region: region)129 XCTAssertLessThanOrEqual(clusters.count, 150, "plafond de rendu pour la fluidité")130 XCTAssertEqual(clusters.flatMap(\.items).count <= 2000, true)131 }132133 @MainActor134 func testRecentsHistoryDedupAndCap() {135 let store = RecentsStore()136 store.clearHistory()137 for i in 0..<40 { store.record(item("h\(i % 35)", lat: 0, lon: 0)) }138 XCTAssertLessThanOrEqual(store.viewed.count, 30, "historique plafonné")139 store.record(item("h1", lat: 0, lon: 0))140 XCTAssertEqual(store.viewed.first?.id, "h1", "re-consulter remonte en tête")141 XCTAssertEqual(store.viewed.filter { $0.id == "h1" }.count, 1, "pas de doublon")142 store.clearHistory()143 XCTAssertTrue(store.viewed.isEmpty)144 }145146 @MainActor147 func testSavedSearchMarkSeen() {148 let store = RecentsStore()149 store.savedSearches = [SavedSearch(name: "t", universeID: "lou-ka", query: "4½",150 alertsOn: true, lastTotal: 100, newCount: 12)]151 XCTAssertEqual(store.alertCount, 12)152 store.markSeen(store.savedSearches[0].id)153 XCTAssertEqual(store.savedSearches[0].newCount, 0)154 XCTAssertEqual(store.savedSearches[0].lastTotal, 112, "le total vu absorbe les nouveautés")155 }156}157158// MARK: - v3.1.0 : personnalisation, agenda Sorti·Ka, hypothèque, chips métier159160final class PersonalizationTests: XCTestCase {161 private func event(_ kind: InteractionKind, u: String? = "lou-ka", city: String? = nil,162 price: Double? = nil, tags: [String] = [], query: String? = nil,163 daysAgo: Double = 0) -> InteractionEvent {164 InteractionEvent(kind: kind, universeID: u, city: city, price: price,165 tags: tags, query: query,166 date: Date().addingTimeInterval(-daysAgo * 86_400))167 }168169 func testAffinityFavoriteBeatsView() {170 let events = [event(.view, u: "lou-ka"), event(.favorite, u: "resto-ka")]171 let p = PreferenceEngine.computeProfile(events)172 XCTAssertEqual(p.topUniverses(1).first, "resto-ka", "un favori pèse plus qu'une vue")173 XCTAssertEqual(p.affinity["resto-ka"] ?? 0, 1.0, accuracy: 0.001, "affinité normalisée à 1")174 }175176 func testDecayOldSignalsFade() {177 let events = [event(.view, u: "auto-ka", daysAgo: 60), event(.view, u: "lou-ka", daysAgo: 0)]178 let p = PreferenceEngine.computeProfile(events)179 XCTAssertEqual(p.topUniverses(1).first, "lou-ka", "le signal frais domine l'ancien")180 }181182 func testUnfavoriteIsNegative() {183 let events = [event(.favorite, u: "job-ka"), event(.unfavorite, u: "job-ka"),184 event(.view, u: "lou-ka")]185 let p = PreferenceEngine.computeProfile(events)186 XCTAssertEqual(p.topUniverses(1).first, "lou-ka", "le retrait annule le favori")187 }188189 func testHasSignalsThreshold() {190 let few = PreferenceEngine.computeProfile([event(.view)])191 XCTAssertFalse(few.hasSignals, "pas de fausse personnalisation avec 1 geste")192 let enough = PreferenceEngine.computeProfile((0..<6).map { _ in event(.view, city: "Québec") })193 XCTAssertTrue(enough.hasSignals)194 XCTAssertEqual(enough.topCities.first, "Québec")195 }196197 func testScorePrefersCityAndPriceBand() {198 let events = (0..<6).map { _ in event(.view, u: "lou-ka", city: "Québec", price: 1200) }199 let p = PreferenceEngine.computeProfile(events)200 func it(_ id: String, city: String?, price: Double?) -> KAItem {201 KAItem(id: id, universeID: "lou-ka", title: "t", subtitle: nil, priceLabel: nil,202 city: city, url: nil, imageURL: nil, latitude: nil, longitude: nil,203 detail: nil, facts: [], price: price)204 }205 let match = PreferenceEngine.score(it("a", city: "Québec", price: 1250), profile: p)206 let off = PreferenceEngine.score(it("b", city: "Gatineau", price: 3900), profile: p)207 XCTAssertGreaterThan(match, off, "ville + gamme de prix doivent booster le score")208 }209210 func testQueriesFeedTags() {211 let events = (0..<5).map { _ in event(.search, u: nil, query: "condo vieux montréal") }212 + [event(.view, u: "immo-ka")]213 let p = PreferenceEngine.computeProfile(events)214 XCTAssertTrue(p.topTags.contains("condo"), "les mots de recherche nourrissent les tags")215 }216}217218final class EventBucketTests: XCTestCase {219 private let fmt: DateFormatter = {220 let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f221 }()222 private func iso(_ daysFromNow: Int) -> String {223 fmt.string(from: Calendar.current.date(byAdding: .day, value: daysFromNow, to: .now)!)224 }225226 func testBuckets() {227 XCTAssertEqual(EventBucket.bucket(iso: iso(0)), .today)228 XCTAssertEqual(EventBucket.bucket(iso: iso(3)), .thisWeek)229 XCTAssertEqual(EventBucket.bucket(iso: iso(20)), .thisMonth)230 XCTAssertEqual(EventBucket.bucket(iso: iso(90)), .later)231 XCTAssertEqual(EventBucket.bucket(iso: iso(-10)), .ongoing, "commencé mais actif (upcoming)")232 XCTAssertEqual(EventBucket.bucket(iso: nil), .undated)233 XCTAssertEqual(EventBucket.bucket(iso: "n'importe quoi"), .undated)234 }235236 func testBucketOrderIsChronological() {237 XCTAssertLessThan(EventBucket.today, EventBucket.thisWeek)238 XCTAssertLessThan(EventBucket.thisWeek, EventBucket.thisMonth)239 XCTAssertLessThan(EventBucket.thisMonth, EventBucket.later)240 }241}242243final class MortgageTests: XCTestCase {244 func testMonthlyPaymentKnownValue() {245 // 400 000 $, 20 % de mise de fonds, 5 %/an, 25 ans → ≈ 1 870,68 $/mois246 let m = MortgageMath.monthlyPayment(price: 400_000, downFraction: 0.20,247 annualRate: 5, years: 25)248 XCTAssertEqual(m, 1870.68, accuracy: 1.0)249 }250251 func testZeroRateIsLinear() {252 let m = MortgageMath.monthlyPayment(price: 120_000, downFraction: 0,253 annualRate: 0, years: 10)254 XCTAssertEqual(m, 1000, accuracy: 0.01)255 }256}257258final class QuickFilterTests: XCTestCase {259 func testChipsExistForCoreUniverses() {260 for id in ["lou-ka", "immo-ka", "auto-ka", "resto-ka", "sorti-ka", "job-ka"] {261 XCTAssertFalse(QuickFilterCatalog.chips(for: id).isEmpty, "chips manquantes : \(id)")262 }263 }264265 func testRestoCuisineSlugsAreRealAPIValues() {266 // slugs vérifiés live le 2026-08-26 (cuisine=sushi retournait 0)267 let valid: Set<String> = ["autre", "bbq-grillades", "burgers", "cafe-dessert",268 "chinois", "dejeuner-brunch", "fruits-de-mer", "indien",269 "italien", "mexicain", "pizza", "poulet", "quebecois",270 "sushi-japonais", "vegetarien-vegan"]271 for chip in QuickFilterCatalog.chips(for: "resto-ka") {272 if let c = chip.params["cuisine"] {273 XCTAssertTrue(valid.contains(c), "slug de cuisine inconnu de l'API : \(c)")274 }275 }276 }277278 func testOldFavoritesJSONStillDecodes() {279 // un favori sérialisé AVANT l'ajout de price/date doit rester lisible280 let old = #"{"id":"lou-ka:1","universeID":"lou-ka","title":"4½","imageURLs":[],"facts":[],"links":[]}"#281 let item = try? JSONDecoder().decode(KAItem.self, from: Data(old.utf8))282 XCTAssertNotNil(item)283 XCTAssertNil(item?.price)284 XCTAssertNil(item?.date)285 }286}287288// MARK: - v2.0.0 : logos officiels + sites intégrés289290final class SitesLogosTests: XCTestCase {291 /// Chaque univers (+ le hub) a son logo officiel dans le catalogue d'assets.292 func testEveryUniverseHasItsOfficialLogo() {293 for id in Ecosystem.all.map(\.id) + ["groupe-ka"] {294 XCTAssertNotNil(UIImage(named: "logo-\(id)"),295 "logo manquant dans Assets.xcassets : logo-\(id)")296 }297 }298299 /// Le navigateur intégré garde http(s) et connaît son URL de départ.300 @MainActor301 func testSiteWebModelStartsOnUniverseSite() {302 guard let u = Ecosystem.universe("lou-ka") else { return XCTFail("lou-ka absent") }303 let model = SiteWebModel(start: u.baseURL)304 XCTAssertEqual(model.currentURL, u.baseURL)305 XCTAssertTrue(model.webView.allowsBackForwardNavigationGestures)306 }307}308