fletchtime 0.2.0__tar.gz → 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. {fletchtime-0.2.0/src/fletchtime.egg-info → fletchtime-0.2.1}/PKG-INFO +1 -1
  2. {fletchtime-0.2.0 → fletchtime-0.2.1}/docs/architecture.md +55 -6
  3. {fletchtime-0.2.0 → fletchtime-0.2.1}/docs/roadmap.md +37 -27
  4. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/__main__.py +4 -0
  5. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/_version.py +3 -3
  6. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/engine/engine.py +22 -1
  7. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/gui.py +52 -2
  8. fletchtime-0.2.1/src/fletchtime/logging_setup.py +56 -0
  9. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/server/match_server.py +37 -0
  10. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/server/ws_server.py +8 -2
  11. {fletchtime-0.2.0 → fletchtime-0.2.1/src/fletchtime.egg-info}/PKG-INFO +1 -1
  12. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime.egg-info/SOURCES.txt +1 -0
  13. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime.egg-info/scm_file_list.json +1 -0
  14. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime.egg-info/scm_version.json +2 -2
  15. {fletchtime-0.2.0 → fletchtime-0.2.1}/tests/test_engine.py +15 -2
  16. {fletchtime-0.2.0 → fletchtime-0.2.1}/tests/test_match_server.py +116 -0
  17. {fletchtime-0.2.0 → fletchtime-0.2.1}/.github/workflows/build.yml +0 -0
  18. {fletchtime-0.2.0 → fletchtime-0.2.1}/.github/workflows/docs.yml +0 -0
  19. {fletchtime-0.2.0 → fletchtime-0.2.1}/.github/workflows/test.yml +0 -0
  20. {fletchtime-0.2.0 → fletchtime-0.2.1}/.gitignore +0 -0
  21. {fletchtime-0.2.0 → fletchtime-0.2.1}/CONTRIBUTING.md +0 -0
  22. {fletchtime-0.2.0 → fletchtime-0.2.1}/LICENSE +0 -0
  23. {fletchtime-0.2.0 → fletchtime-0.2.1}/README.md +0 -0
  24. {fletchtime-0.2.0 → fletchtime-0.2.1}/config/app.toml +0 -0
  25. {fletchtime-0.2.0 → fletchtime-0.2.1}/config/flint.toml +0 -0
  26. {fletchtime-0.2.0 → fletchtime-0.2.1}/config/indoor.toml +0 -0
  27. {fletchtime-0.2.0 → fletchtime-0.2.1}/demo.py +0 -0
  28. {fletchtime-0.2.0 → fletchtime-0.2.1}/docs/_static/logo.svg +0 -0
  29. {fletchtime-0.2.0 → fletchtime-0.2.1}/docs/api-reference.md +0 -0
  30. {fletchtime-0.2.0 → fletchtime-0.2.1}/docs/conf.py +0 -0
  31. {fletchtime-0.2.0 → fletchtime-0.2.1}/docs/dev-guide/index.md +0 -0
  32. {fletchtime-0.2.0 → fletchtime-0.2.1}/docs/index.md +0 -0
  33. {fletchtime-0.2.0 → fletchtime-0.2.1}/docs/requirements.txt +0 -0
  34. {fletchtime-0.2.0 → fletchtime-0.2.1}/docs/specifications.md +0 -0
  35. {fletchtime-0.2.0 → fletchtime-0.2.1}/docs/user-guide/index.md +0 -0
  36. {fletchtime-0.2.0 → fletchtime-0.2.1}/fletchtime.spec +0 -0
  37. {fletchtime-0.2.0 → fletchtime-0.2.1}/pyproject.toml +0 -0
  38. {fletchtime-0.2.0 → fletchtime-0.2.1}/run_server.py +0 -0
  39. {fletchtime-0.2.0 → fletchtime-0.2.1}/run_tests.py +0 -0
  40. {fletchtime-0.2.0 → fletchtime-0.2.1}/scripts/generate_classic_sounds.py +0 -0
  41. {fletchtime-0.2.0 → fletchtime-0.2.1}/setup.cfg +0 -0
  42. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/__init__.py +0 -0
  43. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/engine/__init__.py +0 -0
  44. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/engine/models.py +0 -0
  45. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/engine/modes/__init__.py +0 -0
  46. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/engine/modes/base.py +0 -0
  47. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/engine/modes/flint.py +0 -0
  48. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/engine/modes/indoor.py +0 -0
  49. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/engine/sequence.py +0 -0
  50. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/engine/turn_modes.py +0 -0
  51. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/runtime.py +0 -0
  52. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/server/__init__.py +0 -0
  53. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/server/config_store.py +0 -0
  54. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/server/http_static.py +0 -0
  55. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/web/__init__.py +0 -0
  56. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/web/_defaults/banners/README.md +0 -0
  57. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/web/_defaults/club/README.md +0 -0
  58. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/web/_defaults/gui/theme.json +0 -0
  59. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/web/_defaults/sounds/packs/README.md +0 -0
  60. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/web/_defaults/sounds/packs/classic/countdown_tick.wav +0 -0
  61. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/web/_defaults/sounds/packs/classic/emergency_end.wav +0 -0
  62. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/web/_defaults/sounds/packs/classic/emergency_start.wav +0 -0
  63. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/web/_defaults/sounds/packs/classic/end_of_match.wav +0 -0
  64. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/web/_defaults/sounds/packs/classic/end_of_volee.wav +0 -0
  65. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/web/_defaults/sounds/packs/classic/pause_end.wav +0 -0
  66. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/web/_defaults/sounds/packs/classic/pause_start.wav +0 -0
  67. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/web/_defaults/sounds/packs/classic/prep_start.wav +0 -0
  68. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/web/_defaults/sounds/packs/classic/shoot_start.wav +0 -0
  69. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/web/_defaults/sounds/packs/classic/warning_orange.wav +0 -0
  70. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/web/_defaults/targets/flint_20cm_4spot.jpg +0 -0
  71. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/web/_defaults/targets/flint_35cm_1spot.jpg +0 -0
  72. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/web/_defaults/targets/indoor_compound.jpg +0 -0
  73. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/web/_defaults/targets/indoor_recurve.jpg +0 -0
  74. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/web/config.html +0 -0
  75. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/web/control.html +0 -0
  76. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/web/display.html +0 -0
  77. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/web/i18n.js +0 -0
  78. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/web/index.html +0 -0
  79. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/web/logo.svg +0 -0
  80. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/web/manual.html +0 -0
  81. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/web/screenshots/config.png +0 -0
  82. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/web/screenshots/control.png +0 -0
  83. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/web/screenshots/display.png +0 -0
  84. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/web/screenshots/index.png +0 -0
  85. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime/web/theme.js +0 -0
  86. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime.egg-info/dependency_links.txt +0 -0
  87. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime.egg-info/entry_points.txt +0 -0
  88. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime.egg-info/requires.txt +0 -0
  89. {fletchtime-0.2.0 → fletchtime-0.2.1}/src/fletchtime.egg-info/top_level.txt +0 -0
  90. {fletchtime-0.2.0 → fletchtime-0.2.1}/tests/test_config_store.py +0 -0
  91. {fletchtime-0.2.0 → fletchtime-0.2.1}/tests/test_flint_mode.py +0 -0
  92. {fletchtime-0.2.0 → fletchtime-0.2.1}/tests/test_indoor_mode.py +0 -0
  93. {fletchtime-0.2.0 → fletchtime-0.2.1}/tests/test_runtime.py +0 -0
  94. {fletchtime-0.2.0 → fletchtime-0.2.1}/web/assets/banners/README.md +0 -0
  95. {fletchtime-0.2.0 → fletchtime-0.2.1}/web/assets/sounds/packs/README.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fletchtime
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Chronométrage de compétitions d'archerie FFTL (Indoor, Flint) -- serveur + interfaces web incluses
5
5
  License: GPL-3.0-or-later
6
6
  Requires-Python: >=3.11
@@ -343,6 +343,41 @@ confirmer le rendu et l'ergonomie tactile -- voir aussi le piège
343
343
  PyInstaller/`customtkinter` documenté dans {doc}`dev-guide/index`.
344
344
  ```
345
345
 
346
+ ## Journal applicatif persistant
347
+
348
+ En plus du journal affiché dans la fenêtre graphique (en mémoire,
349
+ perdu à la fermeture -- voir `fletchtime.gui._QueueWriter`),
350
+ `fletchtime.logging_setup.configure_logging` écrit un fichier de journal
351
+ persistant (`<données du club>/logs/fletchtime.log`, avec rotation --
352
+ 1 Mo par fichier, 5 fichiers conservés) : utile pour comprendre après
353
+ coup ce qui s'est passé pendant un concours, sans dépendre de la mémoire
354
+ volatile de la fenêtre.
355
+
356
+ `MatchServer` y journalise :
357
+ - chaque commande reçue (`next`, `stop`, `start_indoor`...), avec la lane
358
+ d'origine si connue ;
359
+ - chaque connexion/déconnexion, avec le nombre de clients restants ;
360
+ - les pertes de connexion réseau détectées (WiFi, mise en veille...),
361
+ distinguées d'une fermeture normale ;
362
+ - les messages malformés reçus (JSON invalide) ;
363
+ - les transitions d'état significatives (`prep_start`, `shoot_start`,
364
+ `warning_orange`, `end_of_volee`, `pause_start`/`pause_end`,
365
+ `emergency_start`/`emergency_end`, `end_of_match`), avec le contexte
366
+ (unité, volée, tour, phase) -- qu'elles soient déclenchées naturellement
367
+ par le décompte ou manuellement (`next`, `goto`...), puisque les deux
368
+ passent par le même mécanisme de collecte d'événements. `countdown_tick`
369
+ est volontairement exclu (se déclenche une fois par seconde dans les
370
+ dernières secondes de chaque volée -- noierait le journal sans valeur
371
+ diagnostique ajoutée).
372
+
373
+ ```{important}
374
+ Le mot de passe (action `authenticate`) n'est **jamais** journalisé -- le
375
+ code ne journalise jamais `data` tel quel, seulement le nom de l'action
376
+ et la lane, précisément pour éviter qu'un secret ne traîne en clair dans
377
+ un fichier qui persiste sur disque. Vérifié par un test dédié
378
+ (`test_password_never_appears_in_logs`).
379
+ ```
380
+
346
381
  ## Récupération après un plantage ou redémarrage du serveur
347
382
 
348
383
  Avant ce mécanisme, un plantage ou un redémarrage du serveur en plein
@@ -398,15 +433,29 @@ comparable à travers un redémarrage du processus.
398
433
  `wallclock_deadline` vaut `None` pendant une pause/urgence (le décompte
399
434
  est gelé, aucune échéance à calculer) : `time_left` reste alors la
400
435
  valeur figée telle quelle, sans recalcul.
436
+ ```
437
+
438
+ ```{important}
439
+ **Si l'échéance est déjà dépassée à la restauration** (le temps
440
+ réellement écoulé pendant l'indisponibilité dépasse ce qui restait sur
441
+ l'étape en cours), le match est marqué **terminé** plutôt que de figer
442
+ `time_left` à 0. Sans ça, le tick normal suivant (`MatchEngine.tick()`,
443
+ voir plus haut) aurait déclenché son rattrapage automatique -- pensé
444
+ pour un simple ralentissement passager du serveur, pas une coupure de
445
+ plusieurs minutes -- et avancé à l'étape suivante avec une durée
446
+ fraîche : le chrono semblait alors "redémarrer" du point de vue des
447
+ archers, alors qu'en réalité bien plus de temps s'était écoulé que ce
448
+ que l'étape en cours pouvait raisonnablement absorber. Une coupure trop
449
+ longue pour être absorbée dans l'étape en cours demande donc une
450
+ intervention explicite du responsable du chronométrage (`restart`/
451
+ `goto`) plutôt qu'une reprise automatique qui devinerait une position
452
+ dans une coupure d'une durée arbitraire.
453
+ ```
401
454
 
402
455
  La sauvegarde à chaque tick (plutôt que périodiquement, ex. toutes les
403
456
  5s) garde malgré tout sa valeur avec ce mécanisme : elle limite la
404
- fenêtre pendant laquelle un **changement d'étape complet** (fin de
405
- volée, passage à l'orange...) pourrait ne pas être capturé avant un
406
- plantage -- l'échéance recalculée donne un temps exact pour l'étape
407
- *enregistrée*, mais ne devine jamais qu'une étape différente aurait dû
408
- être active entre-temps.
409
- ```
457
+ fenêtre pendant laquelle un changement d'étape aurait pu se produire
458
+ juste avant un plantage sans jamais être capturé dans l'instantané.
410
459
 
411
460
  ## Fonctionnement dégradé en cas de coupure réseau
412
461
 
@@ -73,27 +73,41 @@ dépendent d'extensions compilées (Rust/C) peu fiables sur Pydroid 3, voir
73
73
 
74
74
  ## Backlog — à discuter / non encore programmé dans une étape précise
75
75
 
76
+ - ~~**Journal applicatif persistant**~~ -- fait : `MatchServer` journalise
77
+ désormais chaque commande reçue, (dé)connexion, perte de connexion
78
+ réseau et message malformé dans un fichier avec rotation
79
+ (`logs/fletchtime.log`), en plus du journal déjà affiché dans la
80
+ fenêtre (en mémoire, perdu à la fermeture). Voir {doc}`architecture`,
81
+ section dédiée -- vérifié que le mot de passe ne s'y retrouve jamais.
76
82
  - **Fenêtre graphique -- améliorations restant à faire** :
77
83
  - Paramétrage du serveur (ports, etc.) et de l'adresse IP exposée sur le
78
84
  réseau local, directement depuis la fenêtre plutôt que fichiers de
79
85
  config seuls.
80
- - Affichage des adresses exposées (accueil/contrôle/affichage) dans la
81
- fenêtre elle-même, pas seulement via les boutons de raccourci
82
- actuels.
86
+ - ~~Affichage des adresses exposées~~ -- fait : champ dédié (adresse
87
+ d'accueil) dans la fenêtre elle-même, en plus des boutons de
88
+ raccourci -- utile pour retaper l'adresse à la main sur un appareil
89
+ sans bouton de raccourci. Non vérifié visuellement si le texte reste
90
+ bien sélectionnable/copiable une fois le champ désactivé (`CTkEntry`
91
+ n'a pas de vrai état "readonly" contrairement à `ttk.Entry`).
83
92
  - Choix du thème clair/sombre dans la fenêtre -- actuellement forcé en
84
93
  sombre (`ctk.set_appearance_mode("dark")`), cohérent avec
85
94
  `display.html` mais pas ajustable par l'utilisateur comme le sont les
86
95
  autres pages web.
87
- - Couleur du bouton Quitter à revoir (actuellement neutre/transparent).
88
- - ~~**Récupération après plantage/redémarrage du serveur**~~ -- fait :
89
- `MatchServer` persiste un instantané JSON de l'état du match
90
- (`config/match_state.json`, jamais versionné) à chaque commande qui
91
- change l'état et périodiquement pendant le décompte, et tente une
96
+ - ~~Couleur du bouton Quitter à revoir~~ -- fait : couleur pleine
97
+ (`#3a4354`) au lieu de transparent+contour, visible sans être
98
+ alarmante (distincte du rouge d'Arrêter et du doré de Démarrer).
99
+ - ~~**Récupération après plantage/redémarrage du serveur**~~ -- fait et
100
+ **confirmé en conditions réelles avec la v0.2.0** (synchronisation du
101
+ temps testée, plus de retour en arrière du chrono ; reconnexion
102
+ vérifiée) : `MatchServer` persiste un instantané JSON de l'état du
103
+ match (`config/match_state.json`, jamais versionné) à chaque commande
104
+ qui change l'état et périodiquement pendant le décompte, et tente une
92
105
  restauration silencieuse à la construction -- avant ça, un plantage en
93
106
  plein match perdait toute la progression (série, volée, temps écoulé).
94
107
  Voir {doc}`architecture`, section dédiée, pour le détail complet
95
- (écriture atomique, repli silencieux sur un démarrage normal si la
96
- reprise échoue).
108
+ (échéance en horloge murale pour un temps exact quelle que soit la
109
+ durée de l'indisponibilité, écriture atomique, repli silencieux sur un
110
+ démarrage normal si la reprise échoue).
97
111
  - ~~**Revamping du logo**~~ -- fait : nouveau logo cadran/flèche noir-or-blanc,
98
112
  wordmark "FletchTime" bicolore Fletch/Time, cohérent avec le thème clair/sombre.
99
113
  - ~~**Sécurisation cyber**~~ -- en partie fait : mot de passe optionnel
@@ -118,20 +132,16 @@ dépendent d'extensions compilées (Rust/C) peu fiables sur Pydroid 3, voir
118
132
  confirmée) -- voir le détail de l'investigation dans
119
133
  {doc}`dev-guide/index`. Pas de besoin utilisateur macOS avéré à ce jour ;
120
134
  à reconsidérer si ça change.
121
- - **Web app côté client (PWA)** : transformer `control.html`/`display.html`
122
- (voire `index.html`) en Progressive Web App -- `manifest.json` + service
123
- worker + icônes. Permettrait d'"installer" l'app sur l'écran d'accueil
124
- d'une tablette/téléphone (lancement direct, sans retaper l'URL ni passer
125
- par le navigateur), et de mettre en cache les fichiers statiques (HTML/CSS/
126
- JS/logo) pour un chargement instantané même en cas de WiFi capricieux --
127
- le WebSocket, lui, resterait bien sûr en temps réel, seul l'habillage
128
- statique serait mis en cache. Chantier indépendant du reste, à ne traiter
129
- qu'une fois l'interface stabilisée.
130
- - **Documentation Sphinx versionnée** : `docs.yml` publie aujourd'hui une
131
- seule version de la doc sur GitHub Pages, écrasée à chaque nouveau tag
132
- (voir plus bas pourquoi ce n'est plus à chaque push depuis peu) -- pas
133
- de moyen de consulter la doc telle qu'elle était pour une release
134
- précédente (ex. v0.1.2) si le code a changé depuis. À terme : une doc
135
- par tag de version (ex. `/v0.1.2/`, `/v0.1.3/`) en plus d'une version
136
- `/latest/`, avec un sélecteur de version dans le thème Furo (voir
137
- `sphinx-multiversion` ou équivalent).
135
+ - ~~**Web app côté client (PWA)**~~ -- abandonné délibérément : pas jugé
136
+ utile pour l'usage réel du club. (Aurait transformé
137
+ `control.html`/`display.html` en Progressive Web App -- installation
138
+ sur écran d'accueil, mise en cache des fichiers statiques.)
139
+ - ~~**Documentation Sphinx versionnée**~~ -- abandonné délibérément : les
140
+ archives `.tar.gz` jointes à chaque Release GitHub suffisent pour
141
+ consulter la doc d'une version précédente, pas besoin d'un système de
142
+ versionnage complet côté GitHub Pages.
143
+ - ~~**Termux / lancer FletchTime sur Android sans Pydroid**~~ -- abandonné
144
+ délibérément pour l'instant : usage réel actuel = téléphone en simple
145
+ client (accès réseau local), PC pour héberger le serveur. Pas de besoin
146
+ d'exécuter le serveur lui-même sur Android dans ces conditions. À
147
+ reconsidérer si l'usage change.
@@ -28,6 +28,7 @@ from importlib import resources
28
28
  from pathlib import Path
29
29
 
30
30
  from fletchtime import __version__
31
+ from fletchtime.logging_setup import configure_logging
31
32
  from fletchtime.runtime import ServerRuntime
32
33
 
33
34
  HTTP_PORT = 8000
@@ -152,6 +153,9 @@ def _run_headless() -> None:
152
153
  ensure_directories(data_root, app_web_dir)
153
154
  assets_dir = data_root / "web" / "assets"
154
155
 
156
+ log_file = configure_logging(data_root / "logs")
157
+ print(f"Journal détaillé / Detailed log: {log_file}")
158
+
155
159
  _print_banner(local_ip(), data_root)
156
160
 
157
161
  runtime = ServerRuntime(str(app_web_dir), str(assets_dir), HTTP_PORT, WS_PORT)
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.2.0'
22
- __version_tuple__ = version_tuple = (0, 2, 0)
21
+ __version__ = version = '0.2.1'
22
+ __version_tuple__ = version_tuple = (0, 2, 1)
23
23
 
24
- __commit_id__ = commit_id = 'g76ecfc7d6'
24
+ __commit_id__ = commit_id = 'g4c8543661'
@@ -71,7 +71,28 @@ class MatchEngine:
71
71
  # ce cas, le temps reste gelé tel quel.
72
72
  deadline = restore.get("wallclock_deadline")
73
73
  if deadline is not None:
74
- self._time_left = max(0.0, deadline - time.time())
74
+ remaining = deadline - time.time()
75
+ if remaining <= 0:
76
+ # Le temps réellement écoulé pendant l'indisponibilité
77
+ # dépasse ce qui restait sur cette étape -- ne PAS se
78
+ # contenter de figer time_left à 0 : le tick normal
79
+ # suivant aurait alors déclenché le rattrapage
80
+ # automatique de tick() (comportement volontaire et
81
+ # préexistant, pensé pour un simple ralentissement
82
+ # passager, pas une coupure de plusieurs secondes/
83
+ # minutes) et avancé à l'étape suivante avec une durée
84
+ # fraîche -- exactement ce qui ressemblait à "le
85
+ # chrono repart" du point de vue des archers. Une
86
+ # coupure trop longue pour être absorbée dans l'étape
87
+ # en cours marque le match terminé : au responsable du
88
+ # chronométrage de décider explicitement de la suite
89
+ # (restart/goto), plutôt qu'une reprise automatique
90
+ # qui devine une position dans une coupure qui a pu
91
+ # durer n'importe combien de temps.
92
+ self._finished = True
93
+ self._time_left = 0.0
94
+ else:
95
+ self._time_left = remaining
75
96
  # Pas d'appel à _emit_current_step_event() ici, volontairement :
76
97
  # une reprise après crash doit être silencieuse (pas de
77
98
  # prep_start/shoot_start rejoué comme si l'étape venait de
@@ -39,6 +39,7 @@ from fletchtime.__main__ import (
39
39
  ensure_directories,
40
40
  local_ip,
41
41
  )
42
+ from fletchtime.logging_setup import configure_logging
42
43
  from fletchtime.runtime import ServerRuntime
43
44
 
44
45
  _TRANSLATIONS = {
@@ -54,6 +55,7 @@ _TRANSLATIONS = {
54
55
  "status_running": "Serveur en cours -- {ip}",
55
56
  "log_title": "Journal",
56
57
  "club_data": "Données du club :",
58
+ "addressCaption": "Adresse :",
57
59
  },
58
60
  "en": {
59
61
  "title": "FletchTime -- Server",
@@ -67,6 +69,7 @@ _TRANSLATIONS = {
67
69
  "status_running": "Server running -- {ip}",
68
70
  "log_title": "Log",
69
71
  "club_data": "Club data:",
72
+ "addressCaption": "Address:",
70
73
  },
71
74
  }
72
75
 
@@ -193,6 +196,14 @@ class FletchTimeApp(ctk.CTk):
193
196
  sys.stdout = _QueueWriter(sys.stdout, self.log_queue)
194
197
  sys.stderr = _QueueWriter(sys.stderr, self.log_queue)
195
198
 
199
+ # Appelé APRÈS la redirection ci-dessus, volontairement : le
200
+ # StreamHandler créé par configure_logging() se lie au sys.stderr
201
+ # courant au moment de sa création -- fait après le remplacement,
202
+ # les journaux applicatifs (commandes reçues, pertes de connexion...)
203
+ # apparaissent donc aussi dans le widget de journal de la fenêtre,
204
+ # pas seulement dans le fichier persistant.
205
+ self.log_file = configure_logging(self.data_root / "logs")
206
+
196
207
  self.title(self._t("title"))
197
208
  self.geometry("820x600")
198
209
  self.minsize(640, 480)
@@ -281,6 +292,29 @@ class FletchTimeApp(ctk.CTk):
281
292
  )
282
293
  self.display_button.pack(side="left", padx=(0, 12), pady=10, expand=True, fill="x")
283
294
 
295
+ # -- adresse du serveur -----------------------------------------
296
+ # CTkEntry (désactivée après affichage) plutôt qu'un simple
297
+ # libellé : l'intention est de permettre de sélectionner/copier
298
+ # l'adresse (utile pour la retaper à la main sur un appareil sans
299
+ # bouton de raccourci, ex. un téléphone d'archer) -- non vérifié
300
+ # visuellement si "disabled" préserve la sélection de texte sur
301
+ # toutes les plateformes ; à confirmer en conditions réelles.
302
+ address_frame = ctk.CTkFrame(self)
303
+ address_frame.pack(fill="x", padx=16, pady=(0, 8))
304
+
305
+ self.address_caption = ctk.CTkLabel(
306
+ address_frame,
307
+ text=self._t("addressCaption"),
308
+ font=ctk.CTkFont(size=11),
309
+ text_color="gray60",
310
+ )
311
+ self.address_caption.pack(side="left", padx=(12, 8), pady=8)
312
+
313
+ self.address_entry = ctk.CTkEntry(
314
+ address_frame, font=ctk.CTkFont(family="monospace", size=12)
315
+ )
316
+ self.address_entry.pack(side="left", padx=(0, 12), pady=8, expand=True, fill="x")
317
+
284
318
  # -- journal ----------------------------------------------------
285
319
  self.log_label = ctk.CTkLabel(self, text=self._t("log_title"), anchor="w")
286
320
  self.log_label.pack(fill="x", padx=20, pady=(8, 0))
@@ -303,8 +337,8 @@ class FletchTimeApp(ctk.CTk):
303
337
  self,
304
338
  text=self._t("quit"),
305
339
  command=self._on_quit,
306
- fg_color="transparent",
307
- border_width=1,
340
+ fg_color="#3a4354",
341
+ hover_color="#4a5568",
308
342
  )
309
343
  self.quit_button.pack(padx=16, pady=(0, 12))
310
344
 
@@ -332,6 +366,21 @@ class FletchTimeApp(ctk.CTk):
332
366
  else:
333
367
  self.status_dot.configure(text_color="gray50")
334
368
  self.status_label.configure(text=self._t("status_stopped"))
369
+ self._refresh_address()
370
+
371
+ def _refresh_address(self) -> None:
372
+ """L'adresse ne dépend pas de si le serveur tourne réellement --
373
+ affichée dans tous les cas (utile même à l'arrêt, pour préparer
374
+ la config réseau à l'avance sur un autre appareil)."""
375
+ address = f"http://{local_ip()}:{HTTP_PORT}/"
376
+ # CTkEntry ne supporte que "normal"/"disabled" (contrairement à
377
+ # ttk.Entry, qui a un vrai état "readonly") -- il faut donc la
378
+ # débloquer temporairement pour la mettre à jour, puis la
379
+ # reverrouiller aussitôt après.
380
+ self.address_entry.configure(state="normal")
381
+ self.address_entry.delete(0, "end")
382
+ self.address_entry.insert(0, address)
383
+ self.address_entry.configure(state="disabled")
335
384
 
336
385
  def _on_language_change(self, value: str) -> None:
337
386
  self.language = value.lower()
@@ -341,6 +390,7 @@ class FletchTimeApp(ctk.CTk):
341
390
  self.home_button.configure(text=self._t("home"))
342
391
  self.control_button.configure(text=self._t("control"))
343
392
  self.display_button.configure(text=self._t("display"))
393
+ self.address_caption.configure(text=self._t("addressCaption"))
344
394
  self.log_label.configure(text=self._t("log_title"))
345
395
  self.quit_button.configure(text=self._t("quit"))
346
396
  self.footer_label.configure(text=f"{self._t('club_data')} {self.data_root}")
@@ -0,0 +1,56 @@
1
+ """Configuration du journal applicatif : fichier persistant avec rotation,
2
+ en plus de la sortie standard (déjà captée par la fenêtre graphique et
3
+ visible dans un terminal, mais perdue à la fermeture de l'appli -- voir
4
+ fletchtime.gui._QueueWriter). Utile pour comprendre après coup ce qui
5
+ s'est passé pendant un concours (quelles commandes reçues, pertes de
6
+ connexion, erreurs...) sans dépendre de cette mémoire volatile.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import logging.handlers
13
+ from pathlib import Path
14
+
15
+ LOG_FORMAT = "%(asctime)s %(levelname)-8s %(name)s: %(message)s"
16
+ DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
17
+
18
+ # 1 Mo par fichier, 5 fichiers conservés (dont le courant) -- quelques
19
+ # concours de suite avant qu'un fichier ne soit écrasé, sans grossir
20
+ # indéfiniment sur un usage au long cours.
21
+ MAX_BYTES = 1_000_000
22
+ BACKUP_COUNT = 5
23
+
24
+
25
+ def configure_logging(log_dir: Path) -> Path:
26
+ """Idempotent -- rappelable sans dupliquer les handlers si déjà
27
+ configuré (utile si un test ou un rechargement appelle ceci plusieurs
28
+ fois dans le même processus). Retourne le chemin du fichier de
29
+ journal, pour affichage à l'utilisateur si besoin."""
30
+ log_dir.mkdir(parents=True, exist_ok=True)
31
+ log_file = log_dir / "fletchtime.log"
32
+
33
+ logger = logging.getLogger("fletchtime")
34
+ logger.setLevel(logging.INFO)
35
+
36
+ already_has_file_handler = any(
37
+ isinstance(h, logging.handlers.RotatingFileHandler) for h in logger.handlers
38
+ )
39
+ if not already_has_file_handler:
40
+ formatter = logging.Formatter(LOG_FORMAT, datefmt=DATE_FORMAT)
41
+
42
+ file_handler = logging.handlers.RotatingFileHandler(
43
+ log_file, maxBytes=MAX_BYTES, backupCount=BACKUP_COUNT, encoding="utf-8"
44
+ )
45
+ file_handler.setFormatter(formatter)
46
+ logger.addHandler(file_handler)
47
+
48
+ # La sortie standard (stdout) est déjà affichée dans un terminal ou
49
+ # captée par la fenêtre graphique (voir fletchtime.gui) -- ce
50
+ # handler-ci fait en sorte que les logs applicatifs y apparaissent
51
+ # aussi, avec les mêmes horodatages/niveaux que dans le fichier.
52
+ stream_handler = logging.StreamHandler()
53
+ stream_handler.setFormatter(formatter)
54
+ logger.addHandler(stream_handler)
55
+
56
+ return log_file
@@ -11,6 +11,7 @@ from __future__ import annotations
11
11
 
12
12
  import asyncio
13
13
  import json
14
+ import logging
14
15
  import time
15
16
  from dataclasses import asdict, replace
16
17
 
@@ -25,6 +26,8 @@ from . import config_store
25
26
 
26
27
  TICK_INTERVAL = 0.2 # seconds between engine ticks / broadcasts
27
28
 
29
+ logger = logging.getLogger("fletchtime.server")
30
+
28
31
  # Actions qui changent l'état du match ou son affichage -- protégées par mot
29
32
  # de passe si un mot de passe est configuré (voir _auth_required). Tout le
30
33
  # reste (get_config, register_display, authenticate) reste toujours
@@ -110,12 +113,19 @@ class MatchServer:
110
113
 
111
114
  async def register(self, websocket) -> None:
112
115
  self.clients.add(websocket)
116
+ logger.info("Connexion établie (total: %d client(s))", len(self.clients))
113
117
  await self._send_state_to(websocket)
114
118
 
115
119
  async def unregister(self, websocket) -> None:
120
+ lane = self._display_lanes.get(websocket)
116
121
  self.clients.discard(websocket)
117
122
  self._display_lanes.pop(websocket, None)
118
123
  self._authenticated_connections.discard(websocket)
124
+ logger.info(
125
+ "Connexion perdue%s (total: %d client(s) restant(s))",
126
+ f" (lane={lane})" if lane else "",
127
+ len(self.clients),
128
+ )
119
129
 
120
130
  # -- authentication -------------------------------------------------------
121
131
 
@@ -148,9 +158,17 @@ class MatchServer:
148
158
  try:
149
159
  data = json.loads(raw_message)
150
160
  except json.JSONDecodeError:
161
+ logger.warning("Message reçu invalide (pas du JSON) : %r", raw_message[:200])
151
162
  return
152
163
  action = data.get("action")
153
164
 
165
+ # Ne jamais journaliser `data` tel quel : l'action "authenticate"
166
+ # transporte le mot de passe en clair, qui n'a rien à faire dans un
167
+ # fichier de log. Le nom de l'action et la lane (si connue) suffisent
168
+ # amplement pour comprendre après coup ce qui s'est passé.
169
+ lane = self._display_lanes.get(websocket)
170
+ logger.info("Commande reçue : %s%s", action, f" (lane={lane})" if lane else "")
171
+
154
172
  if action == "authenticate":
155
173
  await self._handle_authenticate(data, websocket)
156
174
  return
@@ -411,6 +429,25 @@ class MatchServer:
411
429
  if self.engine is not None:
412
430
  self.engine.tick(elapsed)
413
431
  events = self.engine.pop_pending_events()
432
+ if events:
433
+ state = self.engine.current_state
434
+ for event in events:
435
+ # countdown_tick exclu volontairement : se
436
+ # déclenche une fois par seconde dans les
437
+ # dernières secondes de CHAQUE volée --
438
+ # noierait le journal sans valeur diagnostique
439
+ # ajoutée (le passage à l'orange, lui, est déjà
440
+ # journalisé séparément via warning_orange).
441
+ if event == "countdown_tick":
442
+ continue
443
+ logger.info(
444
+ "Transition : %s (unité %d, volée %d%s, phase=%s)",
445
+ event,
446
+ state.unit_number,
447
+ state.end_number,
448
+ f", {state.current_turn}" if state.current_turn else "",
449
+ state.phase.value,
450
+ )
414
451
  # À chaque tick (~5x/seconde), pas seulement
415
452
  # périodiquement : un instantané périodique trop
416
453
  # espacé (auparavant toutes les 5s) peut être visible
@@ -9,9 +9,12 @@ pages) is served separately by ``fletchtime.server.http_static``.
9
9
  from __future__ import annotations
10
10
 
11
11
  import asyncio
12
+ import logging
12
13
 
13
14
  from .match_server import MatchServer
14
15
 
16
+ logger = logging.getLogger("fletchtime.server")
17
+
15
18
 
16
19
  async def run_ws_server(
17
20
  port: int,
@@ -38,8 +41,11 @@ async def run_ws_server(
38
41
  # Expected when a phone/tablet screen locks or the browser tab
39
42
  # is backgrounded: Android suspends network activity, the
40
43
  # client stops answering keepalive pings, and the connection
41
- # times out. Not an error condition worth a traceback.
42
- pass
44
+ # times out. Not an error condition worth a traceback, mais
45
+ # utile à journaliser : distingue une perte de connexion réseau
46
+ # abrupte d'une fermeture normale (onglet fermé proprement),
47
+ # voir aussi le journal générique de MatchServer.unregister.
48
+ logger.warning("Perte de connexion réseau détectée (WiFi, veille...)")
43
49
  finally:
44
50
  await server_state.unregister(websocket)
45
51
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: fletchtime
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Chronométrage de compétitions d'archerie FFTL (Indoor, Flint) -- serveur + interfaces web incluses
5
5
  License: GPL-3.0-or-later
6
6
  Requires-Python: >=3.11
@@ -28,6 +28,7 @@ src/fletchtime/__init__.py
28
28
  src/fletchtime/__main__.py
29
29
  src/fletchtime/_version.py
30
30
  src/fletchtime/gui.py
31
+ src/fletchtime/logging_setup.py
31
32
  src/fletchtime/runtime.py
32
33
  src/fletchtime.egg-info/PKG-INFO
33
34
  src/fletchtime.egg-info/SOURCES.txt
@@ -12,6 +12,7 @@
12
12
  "src/fletchtime/gui.py",
13
13
  "src/fletchtime/__main__.py",
14
14
  "src/fletchtime/runtime.py",
15
+ "src/fletchtime/logging_setup.py",
15
16
  "src/fletchtime/__init__.py",
16
17
  "src/fletchtime/web/index.html",
17
18
  "src/fletchtime/web/control.html",
@@ -1,7 +1,7 @@
1
1
  {
2
- "tag": "0.2.0",
2
+ "tag": "0.2.1",
3
3
  "distance": 0,
4
- "node": "g76ecfc7d6ca946855ae5248f47e40b8d96ed50f0",
4
+ "node": "g4c854366173a38e914c10115cdfde6cc1c085eab",
5
5
  "dirty": false,
6
6
  "branch": "HEAD",
7
7
  "node_date": "2026-07-19"
@@ -675,7 +675,15 @@ class TestMatchEngineSnapshotRestore(unittest.TestCase):
675
675
  engine2 = MatchEngine(IndoorMode(self._cfg()), restore=snapshot)
676
676
  self.assertEqual(engine2.current_state.time_left, 240.0) # inchangé
677
677
 
678
- def test_restore_clamps_to_zero_after_a_very_long_outage(self) -> None:
678
+ def test_restore_marks_match_finished_after_an_outage_longer_than_time_left(self) -> None:
679
+ """Une coupure plus longue que ce qui restait sur l'étape en cours
680
+ marque le match terminé, plutôt que de figer time_left à 0 --
681
+ sans ça, le tick normal suivant aurait déclenché le rattrapage
682
+ automatique de tick() (pensé pour un simple ralentissement
683
+ passager, pas une coupure de plusieurs minutes) et fait
684
+ redémarrer le chrono sur l'étape suivante avec une durée fraîche,
685
+ ce qui ressemblait exactement à "le chrono repart" du point de
686
+ vue des archers."""
679
687
  from unittest.mock import patch
680
688
 
681
689
  with patch("fletchtime.engine.engine.time.time", return_value=2000.0):
@@ -688,7 +696,12 @@ class TestMatchEngineSnapshotRestore(unittest.TestCase):
688
696
  # 500 vraies secondes plus tard -- largement au-delà des 220s restantes
689
697
  with patch("fletchtime.engine.engine.time.time", return_value=2500.0):
690
698
  engine2 = MatchEngine(IndoorMode(self._cfg()), restore=snapshot)
691
- self.assertEqual(engine2.current_state.time_left, 0.0) # jamais négatif
699
+ self.assertTrue(engine2.current_state.finished)
700
+ self.assertEqual(engine2.current_state.time_left, 0.0)
701
+ # Un tick normal supplémentaire ne doit rien faire avancer :
702
+ # un match terminé reste terminé, pas de redémarrage du chrono.
703
+ engine2.tick(0.2)
704
+ self.assertTrue(engine2.current_state.finished)
692
705
 
693
706
 
694
707
  if __name__ == "__main__":
@@ -1111,5 +1111,121 @@ class TestMatchServerCrashRecovery(unittest.IsolatedAsyncioTestCase):
1111
1111
  self.assertIsNone(server.engine)
1112
1112
 
1113
1113
 
1114
+ class TestMatchServerLogging(unittest.IsolatedAsyncioTestCase):
1115
+ """Couvre le journal applicatif (voir fletchtime.logging_setup) --
1116
+ commandes reçues, (dé)connexions, et surtout l'absence de fuite du
1117
+ mot de passe dans les journaux."""
1118
+
1119
+ async def asyncSetUp(self) -> None:
1120
+ # Efface tout instantané laissé par un test précédent -- voir
1121
+ # setUpModule/tearDownModule plus haut, qui isole déjà le chemin
1122
+ # lui-même, mais pas son contenu entre deux tests de ce fichier.
1123
+ config_store.clear_match_snapshot()
1124
+ # Isole aussi INDOOR_TOML/FLINT_TOML/APP_TOML -- un des tests de
1125
+ # cette classe appelle une vraie sauvegarde de config (mode
1126
+ # indoor, shoot_time=10.0) : sans cette isolation, cet appel
1127
+ # écrivait directement dans le vrai config/indoor.toml du projet
1128
+ # à chaque exécution de la suite de tests (bug réel constaté,
1129
+ # pas hypothétique -- voir l'historique de ce fichier).
1130
+ self._tmpdir = tempfile.TemporaryDirectory()
1131
+ self._original_indoor = config_store.INDOOR_TOML
1132
+ self._original_flint = config_store.FLINT_TOML
1133
+ self._original_app = config_store.APP_TOML
1134
+ config_store.INDOOR_TOML = Path(self._tmpdir.name) / "indoor.toml"
1135
+ config_store.FLINT_TOML = Path(self._tmpdir.name) / "flint.toml"
1136
+ config_store.APP_TOML = Path(self._tmpdir.name) / "app.toml"
1137
+
1138
+ self.server = MatchServer()
1139
+ self.control = FakeWebSocket()
1140
+
1141
+ async def asyncTearDown(self) -> None:
1142
+ config_store.clear_match_snapshot()
1143
+ config_store.INDOOR_TOML = self._original_indoor
1144
+ config_store.FLINT_TOML = self._original_flint
1145
+ config_store.APP_TOML = self._original_app
1146
+ self._tmpdir.cleanup()
1147
+ config_store.clear_match_snapshot()
1148
+
1149
+ async def test_received_command_is_logged(self) -> None:
1150
+ with self.assertLogs("fletchtime.server", level="INFO") as ctx:
1151
+ await self.server.handle_command(json.dumps({"action": "start_indoor"}), self.control)
1152
+ self.assertTrue(any("start_indoor" in line for line in ctx.output))
1153
+
1154
+ async def test_connect_and_disconnect_are_logged(self) -> None:
1155
+ with self.assertLogs("fletchtime.server", level="INFO") as ctx:
1156
+ await self.server.register(self.control)
1157
+ await self.server.unregister(self.control)
1158
+ joined = "\n".join(ctx.output)
1159
+ self.assertIn("établie", joined)
1160
+ self.assertIn("perdue", joined)
1161
+
1162
+ async def test_invalid_json_message_is_logged_as_warning(self) -> None:
1163
+ with self.assertLogs("fletchtime.server", level="WARNING") as ctx:
1164
+ await self.server.handle_command("pas du json {{{", self.control)
1165
+ self.assertTrue(any("invalide" in line for line in ctx.output))
1166
+
1167
+ async def test_password_never_appears_in_logs(self) -> None:
1168
+ """Test de sécurité : l'action authenticate transporte le mot de
1169
+ passe en clair -- il ne doit jamais se retrouver dans un message
1170
+ de journal, où il traînerait en clair dans un fichier persistant."""
1171
+ secret = "SUPER_SECRET_PASSWORD_xyz789"
1172
+ with self.assertLogs("fletchtime.server", level="INFO") as ctx:
1173
+ await self.server.handle_command(
1174
+ json.dumps({"action": "authenticate", "password": secret}), self.control
1175
+ )
1176
+ joined = "\n".join(ctx.output)
1177
+ self.assertNotIn(secret, joined)
1178
+
1179
+ async def test_state_transition_is_logged_with_context(self) -> None:
1180
+ await self.server.handle_command(json.dumps({"action": "start_indoor"}), self.control)
1181
+ self.server.engine.tick(10) # RED -> GREEN
1182
+ self.server.engine.pop_pending_events()
1183
+
1184
+ with self.assertLogs("fletchtime.server", level="INFO") as ctx:
1185
+ await self.server.handle_command(json.dumps({"action": "next"}), self.control)
1186
+ task = asyncio.ensure_future(self.server.tick_loop())
1187
+ await asyncio.sleep(0.3)
1188
+ task.cancel()
1189
+ try:
1190
+ await task
1191
+ except asyncio.CancelledError:
1192
+ pass
1193
+
1194
+ joined = "\n".join(ctx.output)
1195
+ self.assertIn("Transition", joined)
1196
+ self.assertIn("prep_start", joined)
1197
+
1198
+ async def test_countdown_tick_is_never_logged(self) -> None:
1199
+ """countdown_tick se déclenche une fois par seconde dans les
1200
+ dernières secondes de chaque volée -- noierait le journal sans
1201
+ valeur diagnostique ajoutée (contrairement à warning_orange, qui
1202
+ lui doit apparaître)."""
1203
+ await self.server.handle_command(
1204
+ json.dumps(
1205
+ {
1206
+ "action": "save_config",
1207
+ "mode": "indoor",
1208
+ "values": {"shoot_time": 10.0, "orange_warning_time": 3.0},
1209
+ }
1210
+ )
1211
+ )
1212
+ await self.server.handle_command(json.dumps({"action": "start_indoor"}), self.control)
1213
+ self.server.engine.tick(10) # RED -> GREEN(10s)
1214
+ self.server.engine.pop_pending_events()
1215
+ self.server.engine.tick(7.5) # 2.5s restantes -- franchit orange et des ticks
1216
+
1217
+ with self.assertLogs("fletchtime.server", level="INFO") as ctx:
1218
+ task = asyncio.ensure_future(self.server.tick_loop())
1219
+ await asyncio.sleep(0.3)
1220
+ task.cancel()
1221
+ try:
1222
+ await task
1223
+ except asyncio.CancelledError:
1224
+ pass
1225
+
1226
+ joined = "\n".join(ctx.output)
1227
+ self.assertNotIn("countdown_tick", joined)
1228
+
1229
+
1114
1230
  if __name__ == "__main__":
1115
1231
  unittest.main()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes