// 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")) } /// ⚠️ Le message conseillait « fermer complètement l'app Polar Flow ». /// Mesuré faux le 31/08/2026 : la montre affichait « connexion impossible » /// pendant que les réglages iOS la disaient toujours connectée à Flow. iOS /// maintient le lien d'un accessoire appairé, app fermée ou non. func testLeMessageNeConseillePlusDeFermerLApp() { let e = PolarPftpError.watchNotFound(30, vues: 3, sansPsFtp: 2, muettes: 1) XCTAssertTrue(e.description.contains("fermer l'app n'y change rien"), "le geste inutile doit être explicitement écarté") XCTAssertTrue(e.description.contains("Redémarrer la montre"), "le geste qui libère réellement le canal doit être donné") XCTAssertTrue(e.description.contains("câble"), "le chemin fiable doit être rappelé") } /// L'« inconnue restante » a été levée le 31/08 : la montre expose bien /// PsFTP quand elle est connectée. Le message ne doit plus la présenter /// comme une question ouverte. func testDesAppareilsSansPsFtpNePresententPlusUneInconnue() { let e = PolarPftpError.watchNotFound(30, vues: 4, sansPsFtp: 4, muettes: 0) XCTAssertTrue(e.description.contains("n'expose PsFTP")) XCTAssertTrue(e.description.contains("4 appareil")) XCTAssertFalse(e.description.contains("inconnue"), "la question a été tranchée, ne pas la rouvrir dans l'UI") } 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")) } } // MARK: - « On n'a jamais cherché » n'est pas « on n'a rien trouvé » // // `search()` du SDK commence par `monitorBleState().filter { $0 == .poweredOn }`. // Si cet état n'arrive pas, le publisher ne dit RIEN : ni valeur, ni fin, ni // erreur. Le timeout conclut alors « aucun appareil » alors que le scan n'a // jamais démarré. Mesuré le 2026-08-31 : 43 appareils en scan direct, 0 par le // SDK. extension PolarPftpErrorTests { func testPublisherMuetNAccusePasLaMontre() { // ⚠️ Assoupli le 2026-08-31 : le message affirmait « le scan n'a pas // démarré », ce qui était une conclusion de trop — le publisher se tait // aussi quand le scan tourne sans rien découvrir. Ce test vérifie // désormais qu'on n'accuse ni la montre, ni une cause unique. let e = PolarPftpError.sdkSilencieux(vusParCoreBluetooth: 43, publisherAParle: false) XCTAssertTrue(e.description.contains("aucun appareil")) XCTAssertFalse(e.description.contains("Ce n'est pas la montre"), "ne rien affirmer sur la montre : on n'en sait rien ici") } func testUneErreurDuSdkEstRapporteeTelleQuelle() { let e = PolarPftpError.sdkSilencieux(vusParCoreBluetooth: 43, publisherAParle: true, erreurSdk: "bleNotReady") XCTAssertTrue(e.description.contains("bleNotReady")) } func testPublisherActifSansSessionAccuseLIntegration() { let e = PolarPftpError.sdkSilencieux(vusParCoreBluetooth: 43, publisherAParle: true) XCTAssertTrue(e.description.contains("intégration du SDK")) } } // MARK: - Un envoi qui n'aboutit pas doit le dire // // Les `async` du SDK Polar n'ont aucune limite de temps. Le 2026-08-31, un envoi // est resté figé sans message : `waitPsFtpReady` attendait une montre qui ne // finissait pas la négociation. Une attente sans fin est pire qu'une erreur — // elle ne dit rien et n'apprend rien. extension PolarPftpErrorTests { func testLEcheanceDitQuoiFaire() { let e = PolarPftpError.echeanceDepassee(12) XCTAssertTrue(e.description.contains("12")) XCTAssertTrue(e.description.contains("écran")) XCTAssertTrue(e.description.contains("Polar Flow")) } func testLEcheanceNeSeConfondPasAvecUnScanVide() { let echeance = PolarPftpError.echeanceDepassee(12).description let vide = PolarPftpError.watchNotFound(30, vues: 0, sansPsFtp: 0, muettes: 0).description XCTAssertNotEqual(echeance, vide) } } // MARK: - Ne pas conclure plus que ce qu'on a mesuré // // Le publisher de `search()` reste muet dans DEUX cas : le scan n'a pas démarré, // ou il tourne sans rien découvrir (`scanSubject` n'émet que sur découverte). // Un message affirmant le premier a fait chercher au mauvais endroit pendant // trois itérations du 2026-08-31. extension PolarPftpErrorTests { func testLeSilenceDuSdkNAffirmePasQueLeScanEstArrete() { let e = PolarPftpError.sdkSilencieux(vusParCoreBluetooth: 43, publisherAParle: false) XCTAssertTrue(e.description.contains("soit"), "le message doit énoncer les deux possibilités") XCTAssertFalse(e.description.contains("n'a jamais signalé")) } func testAucunPolarListeCeQuiAEteVu() { let e = PolarPftpError.aucunPolarParmi( vues: 43, exemples: ["Apple Watch de Sylvain", "(sans nom)"]) XCTAssertTrue(e.description.contains("43")) XCTAssertTrue(e.description.contains("Apple Watch de Sylvain")) XCTAssertTrue(e.description.contains("Polar Flow"), "l'hypothèse du lien exclusif doit être énoncée") } } // MARK: - Codes d'erreur PFTP (01/09/2026) /// « errorcode 104 » rapporté par Sylvain sur un second envoi vers la même /// date. Ce n'était pas un refus de la montre : la session BLE s'était ouverte, /// et c'est notre séquence de `mkdir` qui n'était pas idempotente. final class PftpCodeTests: XCTestCase { func test104EstBienDirectoryExists() { XCTAssertEqual(PftpCode.directoryExists, 104) } func testUnMkdirSurDossierExistantEstSatisfait() { XCTAssertTrue(PftpCode.mkdirEstSatisfait(par: 104)) } func testLesAutresCodesRestentDesEchecs() { // 103 NO_SUCH_FILE_OR_DIRECTORY : le parent manque, l'ordre des mkdir // est en cause — surtout pas à avaler. XCTAssertFalse(PftpCode.mkdirEstSatisfait(par: 103)) // 105 FILE_EXISTS : l'objectif est déjà écrit. L'appelant DOIT le // savoir — le remplacer n'est visible qu'après redémarrage de la montre. XCTAssertFalse(PftpCode.mkdirEstSatisfait(par: 105)) // 106 OPERATION_NOT_PERMITTED, 108 TIMEOUT : de vrais refus. XCTAssertFalse(PftpCode.mkdirEstSatisfait(par: 106)) XCTAssertFalse(PftpCode.mkdirEstSatisfait(par: 108)) XCTAssertFalse(PftpCode.mkdirEstSatisfait(par: 0)) } }