// Les garde-fous qui empêchent de bloquer une Polar Vantage V3. // // POURQUOI CES TESTS EXISTENT VRAIMENT // // Le 2026-08-17, une requête PFTP malformée — 174 octets écrits vers un chemin // terminé par « / », donc « du contenu dans un dossier » — a bloqué la montre : // logo Polar puis écran noir, récupérée par un appui long sur OK. Le firmware ne // renvoie pas d'erreur applicative sur ce genre de requête, il s'effondre. Le // Bluetooth utilise le même protocole que l'USB : changer de transport n'a rien // enlevé au risque. // // Ces règles ne peuvent donc pas être vérifiées « à la relecture ». Elles sont // la seule chose qui se dresse entre un bug de construction de chemin et une // montre à plusieurs centaines de francs, et elles sont exécutées ici parce que // le reste du plugin (Capacitor, PolarBleSdk) ne compile que sur un Mac. // // Les mêmes règles existent côté serveur dans // `coach_sportif/tools/polar/polar_ftp.py::pftp_put()`. Si l'une des deux // change, l'autre doit suivre. import XCTest @testable import CoachModel final class PolarPftpStepTests: XCTestCase { // MARK: - Les deux formes interdites func testDossierAvecContenuEstRefuse() throws { // La requête exacte qui a planté la montre le 2026-08-17. let etape = PftpStep(path: "/U/0/20260831/TST/180000/TST.BPB/", data: Data(repeating: 0x42, count: 174)) XCTAssertThrowsError(try etape.validate()) { erreur in XCTAssertTrue("\(erreur)".contains("2026-08-17"), "le message doit rappeler l'incident, pas seulement refuser") } } func testFichierVideEstRefuse() { // L'inverse : sans slash final la montre attend un fichier, et un // fichier vide n'a aucun sens — c'est un mkdir mal écrit. let etape = PftpStep(path: "/U/0/20260831/TST/180000/TST.BPB", data: Data()) XCTAssertThrowsError(try etape.validate()) } func testCheminHorsDeUZeroEstRefuse() { // Rien d'autre que /U/0/ n'a jamais été écrit sur cette montre : tout // le reste est une exploration en écriture, donc un risque de blocage. let etape = PftpStep(path: "/SYS/quelquechose.BPB", data: Data([1, 2, 3])) XCTAssertThrowsError(try etape.validate()) } // MARK: - Les deux formes valides func testDossierSansContenuEstAccepte() throws { try PftpStep(path: "/U/0/20260831/TST/", data: Data()).validate() try PftpStep(path: "/U/0/20260831/TST/180000/", data: Data()).validate() } func testFichierAvecContenuEstAccepte() throws { try PftpStep(path: "/U/0/20260831/TST/180000/TST.BPB", data: Data(repeating: 0x42, count: 240)).validate() } // MARK: - L'en-tête PbPFtpOperation func testEnTeteReproduitLaSerialisationPython() { // `encode_operation(PUT, path)` de polar_ftp.py : // 0x08 (champ 1, varint) + 0x01 (PUT) // 0x12 (champ 2, délimité) + longueur + chemin let etape = PftpStep(path: "/U/0/", data: Data()) let attendu = Data([0x08, 0x01, 0x12, 0x05]) + Data("/U/0/".utf8) XCTAssertEqual(etape.header(), attendu) } func testEnTeteAvecCheminReel() { let chemin = "/U/0/20260831/TST/180000/TST.BPB" let entete = PftpStep(path: chemin, data: Data([0x00])).header() XCTAssertEqual(Array(entete.prefix(3)), [0x08, 0x01, 0x12]) XCTAssertEqual(entete[3], UInt8(chemin.utf8.count)) XCTAssertEqual(entete.suffix(chemin.utf8.count), Data(chemin.utf8)) } func testEnTeteNePorteAucunCadrageSerie() { // Le préfixe [0x05, taille, taille] appartient au transport RFC76 de la // version USB. En Bluetooth, le SDK cadre lui-même : le reporter ici // produirait une requête malformée — c'est-à-dire ce qui plante la // montre. Le premier octet doit être le tag protobuf, jamais 0x05. let entete = PftpStep(path: "/U/0/test.BPB", data: Data([1])).header() XCTAssertEqual(entete.first, 0x08) } // MARK: - Varint func testVarintSurUnOctetEnDessousDe128() { XCTAssertEqual(PftpStep.varint(0), [0x00]) XCTAssertEqual(PftpStep.varint(5), [0x05]) XCTAssertEqual(PftpStep.varint(127), [0x7F]) } func testVarintPasseADeuxOctetsA128() { // Un chemin de plus de 127 caractères existe : `/U/0//TST//` // plus un nom de fichier reste court, mais l'encodage doit être juste // pour que la montre lise le bon nombre d'octets. XCTAssertEqual(PftpStep.varint(128), [0x80, 0x01]) XCTAssertEqual(PftpStep.varint(300), [0xAC, 0x02]) } func testEnTeteAvecCheminLongEncodeLaLongueurSurDeuxOctets() { let chemin = "/U/0/" + String(repeating: "a", count: 200) let entete = PftpStep(path: chemin, data: Data([1])).header() XCTAssertEqual(Array(entete[3...4]), PftpStep.varint(chemin.utf8.count)) } // MARK: - Description func testLaDescriptionDistingueMkdirEtPut() { XCTAssertTrue(PftpStep(path: "/U/0/x/", data: Data()).describe.hasPrefix("mkdir")) XCTAssertTrue(PftpStep(path: "/U/0/x.BPB", data: Data([1])).describe.hasPrefix("put")) } } // MARK: - Les trois causes d'échec ne doivent pas se confondre // // Au premier essai Bluetooth du 2026-08-31, un message unique disait « aucune // montre exposant PsFTP » — sans distinguer « rien vu du tout » (Bluetooth // éteint, permission refusée) de « la montre est là mais ne répond pas » (canal // occupé par Polar Flow). Ce sont des causes opposées et des gestes différents. final class PolarPftpErrorTests: XCTestCase { func testAucunAppareilVuPointeVersLIPhone() { let e = PolarPftpError.watchNotFound(30, vues: 0, sansPsFtp: 0, muettes: 0) XCTAssertTrue(e.description.contains("autorisation Bluetooth")) XCTAssertFalse(e.description.contains("Polar Flow"), "sans aucun appareil vu, accuser Flow envoie sur une fausse piste") } func testUneMontreMuettePointeVersFlow() { let e = PolarPftpError.watchNotFound(30, vues: 3, sansPsFtp: 2, muettes: 1) XCTAssertTrue(e.description.contains("Polar Flow")) XCTAssertTrue(e.description.contains("occupé")) } func testDesAppareilsSansPsFtpNommentLInconnueRestante() { let e = PolarPftpError.watchNotFound(30, vues: 4, sansPsFtp: 4, muettes: 0) XCTAssertTrue(e.description.contains("n'expose PsFTP")) XCTAssertTrue(e.description.contains("4 appareil")) } func testLesTroisMessagesSontDistincts() { let messages = Set([ PolarPftpError.watchNotFound(30, vues: 0, sansPsFtp: 0, muettes: 0).description, PolarPftpError.watchNotFound(30, vues: 3, sansPsFtp: 2, muettes: 1).description, PolarPftpError.watchNotFound(30, vues: 4, sansPsFtp: 4, muettes: 0).description, ]) XCTAssertEqual(messages.count, 3) } } // MARK: - « La radio ne marche pas » ≠ « la montre est introuvable » // // Le 2026-08-31, le scan a tourné 30 s et rendu « aucun appareil » — alors que // la vraie cause était en amont : iOS n'avait jamais demandé l'autorisation // Bluetooth, et la ligne n'apparaissait même pas dans les réglages de l'app. // Un message parlant de la montre envoyait chercher au mauvais endroit. extension PolarPftpErrorTests { func testRadioInutilisableNeParlePasDeLaMontre() { let e = PolarPftpError.bluetoothUnusable( "coach n'a pas l'autorisation d'utiliser le Bluetooth.") XCTAssertFalse(e.description.contains("montre")) XCTAssertTrue(e.description.contains("autorisation")) } func testRadioInutilisableEstDistinctDeMontreIntrouvable() { let radio = PolarPftpError.bluetoothUnusable("Bluetooth désactivé").description let montre = PolarPftpError.watchNotFound(30, vues: 0, sansPsFtp: 0, muettes: 0).description XCTAssertNotEqual(radio, montre) } } // MARK: - Radio muette ou SDK muet : ce n'est pas le même diagnostic // // Le 2026-08-31, le SDK ne remontait aucune session et rien ne disait si // CoreBluetooth lui-même voyait quelque chose. Sans cette mesure, impossible de // savoir s'il fallait chercher du côté de la montre ou de notre code. extension PolarPftpErrorTests { func testAucunAppareilEnScanDirectAccuseLEnvironnement() { let e = PolarPftpError.sdkSilencieux(vusParCoreBluetooth: 0) XCTAssertTrue(e.description.contains("hors\u{00A0}de portée") || e.description.contains("hors de portée")) XCTAssertFalse(e.description.contains("intégration")) } func testDesAppareilsEnScanDirectAccusentLeSDK() { let e = PolarPftpError.sdkSilencieux(vusParCoreBluetooth: 12) XCTAssertTrue(e.description.contains("intégration du SDK")) XCTAssertTrue(e.description.contains("12")) XCTAssertTrue(e.description.contains("La radio va bien")) } }