// 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)
}
}