SPB Git forge

spb/ka-ios

Public
20commits 1branches 0releases
17.2 MBsize
maindefault branch
29 days agolast push
Swift 100%
14.0 KB · 308 lines swift
Raw Blame History
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 &amp; 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