Files
coach-ios/tests-linux/Tests/CoachModelTests/PolarPftpStepTests.swift
Sylvain Bettinelli 81582ce969 Une attente sans fin ne dit rien : échéances sur les appels du SDK
L'envoi tournait sans jamais rendre la main, ni message ni erreur. Cause : les
`async` du SDK Polar n'ont aucune limite de temps, et `waitPsFtpReady` attend
indéfiniment une montre qui ne finit pas sa négociation — canal déjà pris, écran
éteint, appairage en cours.

Une attente sans fin est pire qu'un échec : elle n'apprend rien, et c'est
exactement ce qu'on cherche à éviter depuis ce matin. `waitPsFtpReady` a
désormais 12 s, chaque écriture 20 s, et un dépassement compte comme une montre
muette — c'est ce qu'elle est. Le message dit quoi faire : réveiller l'écran,
rapprocher, fermer Flow.

Le fait qu'on soit arrivé jusqu'à ce blocage est en soi une information : avant
le réveil du manager lazy, on n'atteignait même pas la phase de connexion.

82 tests au vert sur tests-linux.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-31 13:17:08 +00:00

261 lines
11 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() {
let e = PolarPftpError.sdkSilencieux(vusParCoreBluetooth: 43,
publisherAParle: false)
XCTAssertTrue(e.description.contains("n'a pas démarré"))
XCTAssertTrue(e.description.contains("Ce n'est pas la montre"))
}
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)
}
}