Files
coach-ios/tests-linux/Tests/CoachModelTests/PolarPftpStepTests.swift
Sylvain Bettinelli 7d68605e83 « errorcode 104 » n'était pas un refus : mkdir sur un dossier déjà là
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>
2026-09-01 08:25:26 +00:00

322 lines
14 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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))
}
}