Le dépôt était bien à jour : c'est moi qui avais mal lu mon propre code. Le message incriminé EST atteignable par la nouvelle version — l'état Bluetooth est publié, on passe au scan, et le publisher se tait quand même. Deux erreurs à moi, pas une du SDK. D'abord le message. 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, et un `prepend` de liste vide n'émet pas. Affirmer « le scan n'a pas démarré » a fait chercher au mauvais endroit pendant trois itérations. Le message énonce désormais les deux possibilités, et le test qui verrouillait l'ancienne formulation vérifie maintenant qu'on n'affirme rien de trop. Ensuite le filtre. `scanPreFilter` rejetait tout ce qui n'avait ni polarDeviceId ni « polar » dans le nom. Si la Vantage ne s'annonce pas ainsi, c'est NOUS qui l'écartions, aucune session n'était créée, et le silence qui suivait était interprété comme une panne du SDK. On laisse donc tout remonter et on trie après : un test de ressemblance volontairement large (polarDeviceId, polarDeviceType, ou nom contenant polar/vantage/grit), et on n'ouvre de session que sur les candidats — ouvrir 43 sessions coûtait des minutes. Et si rien ne ressemble à un Polar, on RAPPORTE ce qu'on a vu : nombre et noms des huit premiers. C'est la seule façon de savoir sous quel nom la montre s'annonce, ou de constater qu'elle ne s'annonce pas du tout — auquel cas l'hypothèse du lien exclusif avec Polar Flow devient la bonne. 84 tests au vert. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
293 lines
13 KiB
Swift
293 lines
13 KiB
Swift
// 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/<date>/TST/<heure>/`
|
||
// 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"))
|
||
}
|
||
}
|
||
|
||
// 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")
|
||
}
|
||
}
|