// Auteur : Simon-Pierre Boucher — contact@spboucher.ai // KATests.swift — tests des parcours critiques hors réseau : mapping JSON → // KAItem pour chaque adaptateur d'univers (échantillons réels des API), // construction des URL de liste, favoris/collections. import XCTest import UIKit @testable import KA final class AdapterTests: XCTestCase { private func decode(_ json: String) -> [String: JSONValue] { let v = try! JSONDecoder().decode(JSONValue.self, from: Data(json.utf8)) return v.object! } func testLouKaMapping() { let u = Ecosystem.universe("lou-ka")! 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"}"#) let item = u.map!(o)! XCTAssertEqual(item.universeID, "lou-ka") XCTAssertEqual(item.title, "4½ Limoilou") XCTAssertEqual(item.priceLabel?.contains("1"), true) XCTAssertEqual(item.city, "Québec") XCTAssertNotNil(item.latitude) XCTAssertTrue(item.facts.contains { $0.label == "Taille" && $0.value == "4½" }) } func testAutoKaMapping() { let u = Ecosystem.universe("auto-ka")! 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"}"#) let item = u.map!(o)! XCTAssertEqual(item.priceLabel, "1 595 $") XCTAssertTrue(item.subtitle?.contains("2010") == true) XCTAssertTrue(item.facts.contains { $0.label == "Marque" && $0.value == "Kia" }) } func testJobKaSalary() { let u = Ecosystem.universe("job-ka")! let o = decode(#"{"uid":"j:1","title":"Analyste","employer":"TD","city":"Montréal","salary_year_min":65000}"#) let item = u.map!(o)! XCTAssertTrue(item.priceLabel?.contains("/an") == true) XCTAssertTrue(item.subtitle?.contains("TD") == true) } func testTrouveKaStripsHTML() { let u = Ecosystem.universe("trouve-ka")! let o = decode(#"{"url":"https://y.qc","title":"La poutine","snippet":"un plat & une légende","domain":"y.qc"}"#) let item = u.map!(o)! XCTAssertEqual(item.title, "La poutine") XCTAssertEqual(item.subtitle, "un plat & une légende") } func testFoodKaIgnoresPlaceholderPrice() { let u = Ecosystem.universe("food-ka")! let o = decode(#"{"uid":"p:1","name":"Prunes","price":0.01,"brand":"PA"}"#) let item = u.map!(o)! XCTAssertNil(item.priceLabel, "un prix placeholder (0,01 $) ne doit pas s'afficher") } func testEveryUniverseHasDistinctAccent() { let accents = Ecosystem.all.map(\.accentHex) XCTAssertEqual(accents.count, Set(accents).count, "chaque univers doit avoir un accent unique") // 12 univers — Groupe-KA (portail mère) est l'app elle-même, pas un univers listé XCTAssertEqual(Ecosystem.all.count, 12) } func testListURLBuilding() { let sorti = Ecosystem.universe("sorti-ka")! let url = UniverseService.listURL(sorti, query: "jazz", limit: 10)! let s = url.absoluteString XCTAssertTrue(s.contains("upcoming=true"), "la query embarquée du listPath doit survivre") XCTAssertTrue(s.contains("q=jazz")) XCTAssertTrue(s.contains("limit=10")) XCTAssertTrue(s.hasPrefix("https://www.sorti-ka.com/api/events")) } @MainActor func testFavoritesToggleAndCollections() { let store = FavoritesStore() let item = KAItem(id: "t:1", universeID: "lou-ka", title: "Test", subtitle: nil, priceLabel: nil, city: nil, url: nil, imageURL: nil, latitude: nil, longitude: nil, detail: nil, facts: []) XCTAssertFalse(store.isFavorite(item)) store.toggle(item) XCTAssertTrue(store.isFavorite(item)) store.addCollection("Week-end") let dest = store.collections.last! store.move(item, to: dest.id) XCTAssertTrue(store.collections.last!.items.contains { $0.id == item.id }) store.toggle(item) XCTAssertFalse(store.isFavorite(item)) } } // MARK: - Refonte : clustering carte + historique/recherches sauvegardées import MapKit final class RefonteTests: XCTestCase { private func item(_ id: String, lat: Double, lon: Double) -> KAItem { KAItem(id: id, universeID: "lou-ka", title: id, subtitle: nil, priceLabel: nil, city: nil, url: nil, imageURL: nil, latitude: lat, longitude: lon, detail: nil, facts: []) } func testClusteringGroupsNearbyAndKeepsSingles() { let region = MKCoordinateRegion(center: .init(latitude: 46.8, longitude: -71.2), span: .init(latitudeDelta: 0.5, longitudeDelta: 0.5)) // 3 points quasi identiques + 1 isolé let items = [ item("a", lat: 46.800, lon: -71.200), item("b", lat: 46.801, lon: -71.201), item("c", lat: 46.8005, lon: -71.2005), item("z", lat: 46.95, lon: -71.05), ] let clusters = ClusterEngine.clusterize(items, region: region) XCTAssertEqual(clusters.count, 2) XCTAssertEqual(clusters.first?.items.count, 3, "les voisins doivent se regrouper") XCTAssertTrue(clusters.contains { $0.isSingle && $0.item.id == "z" }) } func testClusteringCapsRenderCount() { let region = MKCoordinateRegion(center: .init(latitude: 46, longitude: -71), span: .init(latitudeDelta: 10, longitudeDelta: 10)) let many = (0..<2000).map { i in item("i\(i)", lat: 41 + Double(i % 500) * 0.02, lon: -76 + Double(i / 500) * 0.02) } let clusters = ClusterEngine.clusterize(many, region: region) XCTAssertLessThanOrEqual(clusters.count, 150, "plafond de rendu pour la fluidité") XCTAssertEqual(clusters.flatMap(\.items).count <= 2000, true) } @MainActor func testRecentsHistoryDedupAndCap() { let store = RecentsStore() store.clearHistory() for i in 0..<40 { store.record(item("h\(i % 35)", lat: 0, lon: 0)) } XCTAssertLessThanOrEqual(store.viewed.count, 30, "historique plafonné") store.record(item("h1", lat: 0, lon: 0)) XCTAssertEqual(store.viewed.first?.id, "h1", "re-consulter remonte en tête") XCTAssertEqual(store.viewed.filter { $0.id == "h1" }.count, 1, "pas de doublon") store.clearHistory() XCTAssertTrue(store.viewed.isEmpty) } @MainActor func testSavedSearchMarkSeen() { let store = RecentsStore() store.savedSearches = [SavedSearch(name: "t", universeID: "lou-ka", query: "4½", alertsOn: true, lastTotal: 100, newCount: 12)] XCTAssertEqual(store.alertCount, 12) store.markSeen(store.savedSearches[0].id) XCTAssertEqual(store.savedSearches[0].newCount, 0) XCTAssertEqual(store.savedSearches[0].lastTotal, 112, "le total vu absorbe les nouveautés") } } // MARK: - v3.1.0 : personnalisation, agenda Sorti·Ka, hypothèque, chips métier final class PersonalizationTests: XCTestCase { private func event(_ kind: InteractionKind, u: String? = "lou-ka", city: String? = nil, price: Double? = nil, tags: [String] = [], query: String? = nil, daysAgo: Double = 0) -> InteractionEvent { InteractionEvent(kind: kind, universeID: u, city: city, price: price, tags: tags, query: query, date: Date().addingTimeInterval(-daysAgo * 86_400)) } func testAffinityFavoriteBeatsView() { let events = [event(.view, u: "lou-ka"), event(.favorite, u: "resto-ka")] let p = PreferenceEngine.computeProfile(events) XCTAssertEqual(p.topUniverses(1).first, "resto-ka", "un favori pèse plus qu'une vue") XCTAssertEqual(p.affinity["resto-ka"] ?? 0, 1.0, accuracy: 0.001, "affinité normalisée à 1") } func testDecayOldSignalsFade() { let events = [event(.view, u: "auto-ka", daysAgo: 60), event(.view, u: "lou-ka", daysAgo: 0)] let p = PreferenceEngine.computeProfile(events) XCTAssertEqual(p.topUniverses(1).first, "lou-ka", "le signal frais domine l'ancien") } func testUnfavoriteIsNegative() { let events = [event(.favorite, u: "job-ka"), event(.unfavorite, u: "job-ka"), event(.view, u: "lou-ka")] let p = PreferenceEngine.computeProfile(events) XCTAssertEqual(p.topUniverses(1).first, "lou-ka", "le retrait annule le favori") } func testHasSignalsThreshold() { let few = PreferenceEngine.computeProfile([event(.view)]) XCTAssertFalse(few.hasSignals, "pas de fausse personnalisation avec 1 geste") let enough = PreferenceEngine.computeProfile((0..<6).map { _ in event(.view, city: "Québec") }) XCTAssertTrue(enough.hasSignals) XCTAssertEqual(enough.topCities.first, "Québec") } func testScorePrefersCityAndPriceBand() { let events = (0..<6).map { _ in event(.view, u: "lou-ka", city: "Québec", price: 1200) } let p = PreferenceEngine.computeProfile(events) func it(_ id: String, city: String?, price: Double?) -> KAItem { KAItem(id: id, universeID: "lou-ka", title: "t", subtitle: nil, priceLabel: nil, city: city, url: nil, imageURL: nil, latitude: nil, longitude: nil, detail: nil, facts: [], price: price) } let match = PreferenceEngine.score(it("a", city: "Québec", price: 1250), profile: p) let off = PreferenceEngine.score(it("b", city: "Gatineau", price: 3900), profile: p) XCTAssertGreaterThan(match, off, "ville + gamme de prix doivent booster le score") } func testQueriesFeedTags() { let events = (0..<5).map { _ in event(.search, u: nil, query: "condo vieux montréal") } + [event(.view, u: "immo-ka")] let p = PreferenceEngine.computeProfile(events) XCTAssertTrue(p.topTags.contains("condo"), "les mots de recherche nourrissent les tags") } } final class EventBucketTests: XCTestCase { private let fmt: DateFormatter = { let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f }() private func iso(_ daysFromNow: Int) -> String { fmt.string(from: Calendar.current.date(byAdding: .day, value: daysFromNow, to: .now)!) } func testBuckets() { XCTAssertEqual(EventBucket.bucket(iso: iso(0)), .today) XCTAssertEqual(EventBucket.bucket(iso: iso(3)), .thisWeek) XCTAssertEqual(EventBucket.bucket(iso: iso(20)), .thisMonth) XCTAssertEqual(EventBucket.bucket(iso: iso(90)), .later) XCTAssertEqual(EventBucket.bucket(iso: iso(-10)), .ongoing, "commencé mais actif (upcoming)") XCTAssertEqual(EventBucket.bucket(iso: nil), .undated) XCTAssertEqual(EventBucket.bucket(iso: "n'importe quoi"), .undated) } func testBucketOrderIsChronological() { XCTAssertLessThan(EventBucket.today, EventBucket.thisWeek) XCTAssertLessThan(EventBucket.thisWeek, EventBucket.thisMonth) XCTAssertLessThan(EventBucket.thisMonth, EventBucket.later) } } final class MortgageTests: XCTestCase { func testMonthlyPaymentKnownValue() { // 400 000 $, 20 % de mise de fonds, 5 %/an, 25 ans → ≈ 1 870,68 $/mois let m = MortgageMath.monthlyPayment(price: 400_000, downFraction: 0.20, annualRate: 5, years: 25) XCTAssertEqual(m, 1870.68, accuracy: 1.0) } func testZeroRateIsLinear() { let m = MortgageMath.monthlyPayment(price: 120_000, downFraction: 0, annualRate: 0, years: 10) XCTAssertEqual(m, 1000, accuracy: 0.01) } } final class QuickFilterTests: XCTestCase { func testChipsExistForCoreUniverses() { for id in ["lou-ka", "immo-ka", "auto-ka", "resto-ka", "sorti-ka", "job-ka"] { XCTAssertFalse(QuickFilterCatalog.chips(for: id).isEmpty, "chips manquantes : \(id)") } } func testRestoCuisineSlugsAreRealAPIValues() { // slugs vérifiés live le 2026-08-26 (cuisine=sushi retournait 0) let valid: Set = ["autre", "bbq-grillades", "burgers", "cafe-dessert", "chinois", "dejeuner-brunch", "fruits-de-mer", "indien", "italien", "mexicain", "pizza", "poulet", "quebecois", "sushi-japonais", "vegetarien-vegan"] for chip in QuickFilterCatalog.chips(for: "resto-ka") { if let c = chip.params["cuisine"] { XCTAssertTrue(valid.contains(c), "slug de cuisine inconnu de l'API : \(c)") } } } func testOldFavoritesJSONStillDecodes() { // un favori sérialisé AVANT l'ajout de price/date doit rester lisible let old = #"{"id":"lou-ka:1","universeID":"lou-ka","title":"4½","imageURLs":[],"facts":[],"links":[]}"# let item = try? JSONDecoder().decode(KAItem.self, from: Data(old.utf8)) XCTAssertNotNil(item) XCTAssertNil(item?.price) XCTAssertNil(item?.date) } } // MARK: - v2.0.0 : logos officiels + sites intégrés final class SitesLogosTests: XCTestCase { /// Chaque univers (+ le hub) a son logo officiel dans le catalogue d'assets. func testEveryUniverseHasItsOfficialLogo() { for id in Ecosystem.all.map(\.id) + ["groupe-ka"] { XCTAssertNotNil(UIImage(named: "logo-\(id)"), "logo manquant dans Assets.xcassets : logo-\(id)") } } /// Le navigateur intégré garde http(s) et connaît son URL de départ. @MainActor func testSiteWebModelStartsOnUniverseSite() { guard let u = Ecosystem.universe("lou-ka") else { return XCTFail("lou-ka absent") } let model = SiteWebModel(start: u.baseURL) XCTAssertEqual(model.currentURL, u.baseURL) XCTAssertTrue(model.webView.allowsBackForwardNavigationGestures) } }