Sylvain a rapporté un errorcode 104 en testant l'envoi. Le code vient du protocole PFTP de Polar, et pftp_error.proto du SDK officiel le nomme : 104 = DIRECTORY_EXISTS. Ce n'est donc pas un refus de la montre — la session BLE s'était parfaitement ouverte, le transport fonctionnait, et c'est notre séquence de mkdir qui n'était pas idempotente. L'hypothèse fautive était écrite noir sur blanc dans parseSteps : « aucun des deux n'existe d'avance pour une date neuve ». Vrai d'une date neuve, faux dès le second envoi vers la même date — exactement ce que Sylvain venait de faire après avoir réussi un premier envoi. Un mkdir qui bute sur 104 est désormais considéré comme satisfait, à la manière d'un mkdir -p : le dossier est là, c'est tout ce qu'on lui demandait. Strictement limité à la création de dossier — un put de fichier n'est jamais avalé, 105 FILE_EXISTS signifierait que l'objectif est déjà écrit et l'appelant doit le savoir. Le renvoi n'est pas passé sous silence pour autant : ecrire() rend un drapeau dossierPreexistant, le plugin le résout en alreadyExisted, et l'UI de coach affiche « Un objectif existait déjà à cette date. Redémarrer la montre pour que le nouveau s'affiche. » C'est la contrepartie de l'index en cache constaté le 31/08 — sans cet avertissement, l'envoi annoncerait un succès que la montre ne montrerait pas. Les codes PFTP sont définis dans PolarPftpStep.swift, donc couverts par les tests Linux : 87 tests verts, dont 3 nouveaux qui vérifient que 104 est satisfaisant pour un mkdir et que 103, 105, 106 et 108 restent des échecs. ⚠️ CoachPolarBLE.swift n'est pas compilable ici — Capacitor et le SDK Polar n'existent pas sur Linux. La modification du plugin est à vérifier au prochain build Xcode ; la logique des codes, elle, est testée. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
322 lines
14 KiB
Swift
322 lines
14 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")
|
||
}
|
||
}
|
||
|
||
// 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))
|
||
}
|
||
}
|