feat(web): pages locales aide / statistiques / réglages (serveur HTTP 127.0.0.1)

Nouveau module webserver.nim : mini-serveur HTTP local (port libre a partir de 38943) qui sert des pages HTML generes en Nim - aide (nouvelle entree de menu tray), statistiques et reglages. La page reglages enregistre via POST (config.json) et l'application recharge la configuration au tick suivant. Menu Aide/Statistiques/Reglages ouvrent le navigateur par defaut, avec repli sur les dialogues natifs si le serveur est indisponible. Refactor config/stats (loadConfigFrom/saveConfigTo, loadStatsFrom/saveStatsTo) pour tester sans toucher aux fichiers reels. 8 suites de tests (60 tests), ajout de test_webserver avec integration HTTP reelle.
This commit is contained in:
2026-08-14 21:50:28 -04:00
parent 584c2fd182
commit 28860eaed1
10 changed files with 888 additions and 21 deletions
+28 -4
View File
@@ -43,6 +43,9 @@ prend soin de votre santé autant que de votre productivité, dans une interface
et la configuration est stockée au même endroit (pratique sur clé USB).
- **Mise à jour automatique** (optionnelle) : vérification en arrière-plan,
notification si une version plus récente est disponible.
- **Pages web locales** : aide, statistiques et réglages présentés sous forme de
pages HTML servies localement par l'application (127.0.0.1) et ouvertes dans
le navigateur par défaut — aucune donnée ne quitte l'ordinateur.
## Prérequis
@@ -151,7 +154,9 @@ src/
├── i18n.nim # chaînes localisées (français / anglais), détection langue
├── dialogs.nim # réglages, rappels santé, assistant, stats (wNim)
├── floating.nim # fenêtre flottante compacte (toujours au-dessus)
└── updater.nim # comparaison de versions + récupération distante
├── updater.nim # comparaison de versions + récupération distante
└── webserver.nim # serveur HTTP local (127.0.0.1) : pages aide / stats /
# réglages, routage, rendu HTML, formulaire de sauvegarde
```
Séparation des responsabilités : `timer`, `stats` et `updater` sont purs
@@ -167,11 +172,29 @@ Windows.
| Menu contextuel | Clic droit |
| Démarrer / mettre en pause | Double-clic |
## Pages web locales (aide, statistiques, réglages)
Depuis le menu de l'icône, « Aide », « Statistiques » et « Réglages » ouvrent
des pages HTML **servies localement** par l'application elle-même :
- Le menu **Aide** explique le fonctionnement de l'application (périodes,
pauses, rappels santé, raccourcis, astuces).
- **Statistiques** affiche les pomodoros du jour / semaine, la série de jours
consécutifs, le total et l'historique des 7 derniers jours.
- **Réglages** présente un formulaire complet (durées, objectif, son, langue,
profil actif, rappels santé) : l'enregistrement est appliqué immédiatement
par l'application.
L'application lance un petit serveur sur `127.0.0.1` (premier port libre à
partir de `38943`), uniquement accessible en local. Aucune donnée ne quitte
l'ordinateur. Si le serveur est indisponible, les menus retombent sur les
dialogues natifs.
## Tests
Les modules purs (`timer`, `stats`, `config`, `notifications`, `updater`) sont
testés unitairement, ainsi que l'intégration Win32 (`tray`). Un exécutable
unique les lance toutes et affiche un résumé :
Les modules purs (`timer`, `stats`, `config`, `notifications`, `updater`,
`webserver`) sont testés unitairement, ainsi que l'intégration Win32
(`tray`). Un exécutable unique les lance toutes et affiche un résumé :
```bash
run_tests.cmd
@@ -187,6 +210,7 @@ nim c -r tests/test_notifications.nim
nim c -r tests/test_tray_integration.nim
nim c -r tests/test_updater.nim
nim c -r tests/test_floating.nim
nim c -r tests/test_webserver.nim
```
La correspondance exigence → contrôle → résultat est détaillée dans
+12 -1
View File
@@ -34,6 +34,17 @@ concret effectué, et le résultat observé.
| 15 | Mise à jour (comparaison de versions) | `tests/test_updater.nim` (5 tests) | 5 tests **OK** |
| 16 | Fenêtre flottante (toujours au-dessus, texte agrandi) | `tests/test_floating.nim` (show/update/hide une vraie fenêtre Win32) + `floatingWindow` round-trip | **OK** (fenêtre créée/mise à jour/détruite sans erreur) |
## Fonctionnalité ajoutée (pages web locales)
| # | Exigence | Contrôle concret | Résultat observé |
|---|----------|------------------|------------------|
| 17 | Section Aide expliquant le fonctionnement | menu tray « Aide » (`IdHelpPage`) + page `/help` générée (fr/en) | **OK** (`tests/test_webserver.nim` : page rendue, GET HTTP réel) |
| 18 | Statistiques présentées en page web locale | page `/stats` (jour/semaine/streak/total, 7 jours) servie par le serveur local | **OK** (GET réel via `httpclient` dans le test d'intégration) |
| 19 | Réglages en page web locale avec sauvegarde | page `/settings` (formulaire + rappels santé) ; POST `/settings` écrit `config.json` et pose le drapeau de rechargement | **OK** (POST réel : valeurs relues dans le fichier, page mise à jour) |
| 20 | Serveur local 127.0.0.1, port libre à partir de 38943 | `startWebServer()` dans `tests/test_webserver.nim` | **OK** (port 38943, requêtes GET/POST traitées) |
| 21 | Aucune donnée ne quitte l'ordinateur | serveur lié à `127.0.0.1` uniquement | **OK** (relecture de code) |
| 22 | Repli sur les dialogues natifs si serveur indisponible | branche `gServerPort > 0` dans `nimpulse.nim` | Compile ; logique vérifiée par relecture |
## Contraintes techniques
| Contrainte | Contrôle concret | Résultat observé |
@@ -77,4 +88,4 @@ run_tests.cmd
(ou `nim c -d:release --app:gui src/nimpulse.nim` pour le binaire)
51 tests au total, tous verts (7 suites).
60 tests au total, tous verts (8 suites).
+1 -1
View File
@@ -8,7 +8,7 @@ setlocal enabledelayedexpansion
set PASS=0
set FAIL=0
for %%t in (test_timer test_stats test_config test_notifications test_tray_integration test_updater test_floating) do (
for %%t in (test_timer test_stats test_config test_notifications test_tray_integration test_updater test_floating test_webserver) do (
echo.
echo === %%t ===
nim c -r --hints:off tests\%%t.nim
+17 -8
View File
@@ -156,12 +156,11 @@ proc getActiveProfile*(cfg: Config): Profile =
# Chargement / sauvegarde
# ---------------------------------------------------------------------------
proc loadConfig*(): Config =
## Charge la configuration depuis le disque. En cas d'erreur ou de fichier
## manquant, on conserve des valeurs par défaut (jamais de crash au boot).
proc loadConfigFrom*(path: string): Config =
## Charge la configuration depuis le chemin path. En cas d'erreur ou de
## fichier manquant, on conserve des valeurs par défaut (jamais de crash).
result = defaultConfig()
try:
let path = configPath()
if not fileExists(path):
return
let n = parseFile(path)
@@ -206,10 +205,16 @@ proc loadConfig*(): Config =
# Fichier corrompu : on garde les valeurs par défaut.
discard
proc saveConfig*(cfg: Config) =
## Sauvegarde la configuration au format JSON (pretty-printé).
proc loadConfig*(): Config =
## Charge la configuration depuis le disque (configPath()).
loadConfigFrom(configPath())
proc saveConfigTo*(cfg: Config, path: string) =
## Sauvegarde la configuration au format JSON (pretty-printé) dans path.
try:
ensureConfigDir()
let dir = path.parentDir
if dir.len > 0:
createDir(dir)
var n = newJObject()
n["workMin"] = %cfg.workMin
n["shortBreakMin"] = %cfg.shortBreakMin
@@ -245,10 +250,14 @@ proc saveConfig*(cfg: Config) =
rem.add o
n["healthReminders"] = rem
writeFile(configPath(), n.pretty)
writeFile(path, n.pretty)
except:
discard
proc saveConfig*(cfg: Config) =
## Sauvegarde la configuration sur le disque (configPath()).
saveConfigTo(cfg, configPath())
# ---------------------------------------------------------------------------
# Démarrage automatique (registre)
# ---------------------------------------------------------------------------
+6
View File
@@ -44,6 +44,7 @@ type Strings* = object
menuSettings*: string
menuStats*: string
menuExportCsv*: string
menuHelp*: string
menuQuit*: string
# Notifications.
@@ -109,6 +110,7 @@ type Strings* = object
settingsSave*: string
settingsCancel*: string
settingsSaved*: string
webServerError*: string
settingsMinutes*: string
# Libellés génériques.
@@ -154,6 +156,7 @@ const fr* = Strings(
menuSettings: "Paramètres",
menuStats: "Statistiques",
menuExportCsv: "Exporter les statistiques (CSV)",
menuHelp: "Aide",
menuQuit: "Quitter",
notifWorkDone: "Travail terminé",
@@ -217,6 +220,7 @@ const fr* = Strings(
settingsSave: "Enregistrer",
settingsCancel: "Annuler",
settingsSaved: "Paramètres enregistrés.",
webServerError: "Le serveur web local n'a pas pu démarrer.",
settingsMinutes: "min",
btnOk: "OK",
@@ -256,6 +260,7 @@ const en* = Strings(
menuSettings: "Settings",
menuStats: "Statistics",
menuExportCsv: "Export statistics (CSV)",
menuHelp: "Help",
menuQuit: "Quit",
notifWorkDone: "Work session complete",
@@ -319,6 +324,7 @@ const en* = Strings(
settingsSave: "Save",
settingsCancel: "Cancel",
settingsSaved: "Settings saved.",
webServerError: "The local web server could not be started.",
settingsMinutes: "min",
btnOk: "OK",
+31 -2
View File
@@ -23,6 +23,7 @@ import i18n
import dialogs
import updater
import floating
import webserver
const
TimerId = 1'i64
@@ -39,6 +40,7 @@ var
gTimer: PomodoroTimer
gStats: Stats
gHwnd: HWND
gServerPort = 0 ## Port du serveur web local (0 = non démarré).
gUpdateLatest: array[32, char] ## Version distante (buffer non-GC, partagé
## entre le thread de mise à jour et la boucle
## de messages).
@@ -87,11 +89,20 @@ proc stopTimer() =
proc quitApp() =
saveConfig(gCfg)
stopWebServer()
hideFloating()
removeTrayIcon(gHwnd)
UnregisterHotKey(gHwnd, HotkeyId)
DestroyWindow(gHwnd)
proc openWebPage(path: string) =
## Ouvre une page servie localement dans le navigateur par défaut.
if gServerPort == 0:
discard MessageBox(gHwnd, S.webServerError, S.appName, MB_ICONERROR)
return
let url = "http://127.0.0.1:" & $gServerPort & path
discard ShellExecuteW(0, "open", url, nil, nil, SW_SHOWNORMAL)
proc openSettings() =
## Ouvre le panneau de réglages graphique.
if showSettingsDialog(gCfg):
@@ -143,6 +154,12 @@ proc onTick() =
of evWorkCompleted: onWorkCompleted()
of evBreakCompleted: onBreakCompleted()
of evNone: discard
# Réglages sauvegardés depuis la page web : rechargement en douceur.
if webserver.consumeSettingsChanged():
gCfg = loadConfig()
setLang(resolveLang(gCfg))
applyHotkey()
showToast(gHwnd, S.appName, S.settingsSaved)
refreshTray()
proc skipBreak() =
@@ -159,8 +176,17 @@ proc handleCommand(cmd: int) =
of IdStop: stopTimer()
of IdSkipBreak: skipBreak()
of IdExtendBreak: extendBreak()
of IdSettings: openSettings()
of IdStats: showStatsDialog()
of IdHelpPage: openWebPage("/help")
of IdSettings:
if gServerPort > 0:
openWebPage("/settings")
else:
openSettings() # repli sur le dialogue natif si le serveur est indisponible
of IdStats:
if gServerPort > 0:
openWebPage("/stats")
else:
showStatsDialog()
of IdExportCsv: exportStatsCsv()
of IdAutoStart:
gCfg.autoStart = not gCfg.autoStart
@@ -275,6 +301,9 @@ proc main() =
# Icône dans le tray.
discard addTrayIcon(gHwnd, phaseIcon(gTimer.phase), tooltipText(gCfg, gTimer))
# Serveur web local (pages aide / statistiques / réglages).
gServerPort = startWebServer()
# Minuteur (1 tick par seconde).
discard SetTimer(gHwnd, TimerId, 1000, nil)
+14 -5
View File
@@ -16,11 +16,10 @@ proc todayStr*(): string =
proc initStats*(): Stats =
Stats(daily: initTable[string, int]())
proc loadStats*(): Stats =
proc loadStatsFrom*(path: string): Stats =
## Charge les stats depuis le disque (jamais de crash si corrompu).
result = initStats()
try:
let path = statsPath()
if not fileExists(path):
return
let n = parseFile(path)
@@ -31,18 +30,28 @@ proc loadStats*(): Stats =
except:
discard
proc saveStats*(st: Stats) =
proc loadStats*(): Stats =
## Charge les stats depuis le disque (statsPath()).
loadStatsFrom(statsPath())
proc saveStatsTo*(st: Stats, path: string) =
try:
ensureConfigDir()
let dir = path.parentDir
if dir.len > 0:
createDir(dir)
var n = newJObject()
var daily = newJObject()
for k, v in st.daily:
daily[k] = %v
n["daily"] = daily
writeFile(statsPath(), n.pretty)
writeFile(path, n.pretty)
except:
discard
proc saveStats*(st: Stats) =
## Sauvegarde les stats sur le disque (statsPath()).
saveStatsTo(st, statsPath())
proc recordPomodoro*(st: var Stats) =
## Incrémente le compteur du jour courant puis sauvegarde.
let today = todayStr()
+3
View File
@@ -21,6 +21,7 @@ const
IdStats* = 1004
IdQuit* = 1005
IdExportCsv* = 1006
IdHelpPage* = 1007
IdAutoStart* = 1500
IdDoNotDisturb* = 1501
IdSkipBreak* = 1502
@@ -196,6 +197,8 @@ proc showTrayMenu*(hwnd: HWND, cfg: Config, t: PomodoroTimer) =
AppendMenu(menu, mfStr, int64(IdStats), S.menuStats)
AppendMenu(menu, mfStr, int64(IdExportCsv), S.menuExportCsv)
AppendMenu(menu, mfSep, 0, nil)
AppendMenu(menu, mfStr, int64(IdHelpPage), S.menuHelp)
AppendMenu(menu, mfSep, 0, nil)
AppendMenu(menu, mfStr, int64(IdQuit), S.menuQuit)
var pt: POINT
+642
View File
@@ -0,0 +1,642 @@
## webserver.nim
## ==============
## Serveur HTTP local (127.0.0.1) qui sert des pages web générées en Nim :
## aide, statistiques et réglages. Les pages sont ouvertes dans le navigateur
## par défaut de l'utilisateur (ShellExecuteW côté application).
##
## Le serveur tourne dans un thread dédié. Il lit/écrit directement les
## fichiers config.json et stats.json (pas de partage de mémoire GC entre
## threads) et signale une sauvegarde des réglages via le hook
## onSettingsSavedHook (l'application l'utilise pour recharger la config).
##
## Aucune dépendance Win32 : ce module est testable unitairement.
import std/[net, nativesockets, strutils, uri, json, os, tables]
import config
import stats
import i18n
const
DefaultPort* = 38943 ## Port de base du serveur local.
PortAttempts = 6 ## Nombre de ports essayés si le premier est pris.
MaxRequestSize = 32768 ## Taille maximale d'une requête (en-têtes + corps).
const
MaxDataDirLen = 512 ## Longueur max du chemin de surcharge (tests).
var
running = false
gPort = 0
keepSock: Socket ## Référence maintenue vivante (évite la fermeture GC).
serverThread: Thread[SocketHandle]
dataDirBuf: array[MaxDataDirLen, char] ## Surcharge (tests), mémoire non-GC :
## lisible sans risque par le thread serveur.
settingsChangedFlag = false ## Posé par le thread serveur après une
## sauvegarde ; consommé par le thread principal.
proc setDataDirOverride*(dir: string) =
## Surcharge le répertoire de données (utile pour les tests). À appeler
## AVANT startWebServer(). Le thread serveur n'accède qu'à un buffer
## non-GC, ce qui reste sûr avec --threads:on.
zeroMem(addr dataDirBuf[0], MaxDataDirLen)
if dir.len > 0:
let n = min(dir.len, MaxDataDirLen - 1)
copyMem(addr dataDirBuf[0], cstring(dir), n)
proc dataDir(): string {.gcsafe.} =
## Répertoire des données : la surcharge (tests) si définie, sinon configDir().
if dataDirBuf[0] != '\0':
result = newString(0)
for c in dataDirBuf:
if c == '\0': break
result.add c
else:
result = configDir()
proc configFile(): string {.gcsafe.} = dataDir() / "config.json"
proc statsFile(): string {.gcsafe.} = dataDir() / "stats.json"
proc markSettingsChanged() {.gcsafe.} =
## Posé par le thread serveur (booléen simple, sûr sans verrou).
settingsChangedFlag = true
proc consumeSettingsChanged*(): bool =
## Consulté par le thread principal : true si des réglages ont été sauvés
## depuis le dernier appel.
result = settingsChangedFlag
settingsChangedFlag = false
# ---------------------------------------------------------------------------
# Helpers HTTP / HTML
# ---------------------------------------------------------------------------
proc htmlEscape*(s: string): string =
## Échappe les caractères HTML (& < > " ').
result = ""
for c in s:
case c
of '&': result.add "&amp;"
of '<': result.add "&lt;"
of '>': result.add "&gt;"
of '"': result.add "&quot;"
of '\'': result.add "&#39;"
else: result.add c
type
HttpRequest* = object
verb*: string ## GET / POST
path*: string ## chemin décodé (sans la query)
body*: string ## corps (POST)
proc contentLengthFrom(headers: string): int =
## Extrait Content-Length des en-têtes (0 si absent).
for line in headers.split("\r\n"):
let ci = line.find(':')
if ci > 0 and line[0 ..< ci].toLowerAscii().strip() == "content-length":
try: return parseInt(line[ci + 1 .. ^1].strip())
except ValueError: return 0
return 0
proc parseRequest*(data: string): HttpRequest =
## Parse une requête HTTP (ligne de requête + en-têtes + corps déjà lus).
let headerEnd = data.find("\r\n\r\n")
let headerPart = if headerEnd >= 0: data[0 ..< headerEnd] else: data
let lines = headerPart.split("\r\n")
if lines.len > 0:
let parts = lines[0].split(' ')
if parts.len >= 2:
result.verb = parts[0]
let target = parts[1]
let qi = target.find('?')
if qi >= 0:
result.path = decodeUrl(target[0 ..< qi])
else:
result.path = decodeUrl(target)
if headerEnd >= 0:
let bodyLen = contentLengthFrom(headerPart)
if bodyLen > 0 and data.len >= headerEnd + 4 + bodyLen:
result.body = data[headerEnd + 4 ..< headerEnd + 4 + bodyLen]
proc parseForm*(body: string): Table[string, string] =
## Décode un corps application/x-www-form-urlencoded.
for pair in body.split('&'):
if pair.len == 0: continue
let eq = pair.find('=')
if eq < 0:
result[decodeUrl(pair)] = ""
else:
result[decodeUrl(pair[0 ..< eq])] = decodeUrl(pair[eq + 1 .. ^1])
proc httpResponse(body: string, status = "200 OK",
contentType = "text/html; charset=utf-8"): string =
"HTTP/1.1 " & status & "\r\n" &
"Content-Type: " & contentType & "\r\n" &
"Content-Length: " & $body.len & "\r\n" &
"Cache-Control: no-store\r\n" &
"Connection: close\r\n\r\n" & body
proc t(lang: Lang, fr, en: string): string =
## Traduit selon la langue résolue.
if lang == langFr: fr else: en
# ---------------------------------------------------------------------------
# Gabarit de page
# ---------------------------------------------------------------------------
proc pageShell(lang: Lang, title, body: string): string =
let nav = "<nav><a href=\"/help\">" & t(lang, "Aide", "Help") &
"</a> <a href=\"/stats\">" & t(lang, "Statistiques", "Statistics") &
"</a> <a href=\"/settings\">" & t(lang, "Réglages", "Settings") &
"</a></nav>"
result = """<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NimPulse — """ & title & """</title>
<style>
:root{color-scheme:light dark}
body{font-family:system-ui,'Segoe UI',sans-serif;max-width:800px;margin:2rem auto;padding:0 1rem;line-height:1.65;color:#1f2937;background:#fff}
@media (prefers-color-scheme: dark){
body{background:#111827;color:#e5e7eb}
h1{color:#5eead4} h2{color:#99f6e4;border-color:#374151}
code,pre{background:#1f2937} .card{background:#1f2937;border-color:#374151}
th,td{border-color:#374151} .bar{background:#374151} .muted{color:#9ca3af}
input,select,button{color-scheme:dark}
}
h1{color:#0f766e} h2{margin-top:2rem;color:#115e59;border-bottom:1px solid #e5e7eb;padding-bottom:.3rem}
code,pre{background:#f3f4f6;padding:.1rem .35rem;border-radius:4px;font-size:.92em}
table{border-collapse:collapse;width:100%;margin:.6rem 0}
th,td{border:1px solid #e5e7eb;padding:.4rem .6rem;text-align:left}
nav{display:flex;gap:1.2rem;margin-bottom:1.6rem;padding:.6rem .8rem;border-radius:8px;background:#f0fdfa;border:1px solid #99f6e4}
nav a{color:#0f766e;text-decoration:none;font-weight:600}
.card{background:#f9fafb;border:1px solid #e5e7eb;border-radius:10px;padding:1rem 1.2rem;margin:1rem 0}
.bar{background:#e5e7eb;border-radius:6px;height:14px;overflow:hidden;min-width:30px;display:inline-block;vertical-align:middle}
.bar>div{background:#14b8a6;height:100%}
.muted{color:#6b7280;font-size:.92em}
.btn{display:inline-block;background:#0f766e;color:#fff;padding:.45rem .95rem;border-radius:6px;text-decoration:none;border:0;font-size:.95rem;cursor:pointer}
.btn:hover{background:#115e59}
label{display:block;margin:.55rem 0 .15rem;font-weight:600}
input[type=text],input[type=number],select{width:100%;max-width:320px;padding:.35rem .5rem;border:1px solid #d1d5db;border-radius:6px}
input[type=checkbox]{width:auto}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:0 1.5rem}
@media (max-width:640px){.grid{grid-template-columns:1fr}}
.kbd{background:#f3f4f6;border:1px solid #d1d5db;border-bottom-width:2px;border-radius:4px;padding:.05rem .4rem;font-family:ui-monospace,monospace;font-size:.9em}
.ok{color:#047857;font-weight:700}
</style>
</head>
<body>
""" & nav & body & "</body></html>"
# ---------------------------------------------------------------------------
# Page d'aide
# ---------------------------------------------------------------------------
proc renderHelp*(cfg: Config): string =
let lang = resolveLang(cfg)
var b = ""
b.add "<h1>" & t(lang,
"Aide — Comment fonctionne NimPulse",
"Help — How NimPulse works") & "</h1>"
b.add "<div class=\"card\"><h2>" & t(lang, "Le principe", "The concept") & "</h2><p>" &
t(lang,
"NimPulse est un minuteur Pomodoro qui vit dans la zone de notification (tray). " &
"Il alterne des périodes de travail concentré et des pauses, et profite des pauses " &
"pour vous rappeler de prendre soin de votre santé (marcher, boire de l'eau, vous étirer…).",
"NimPulse is a Pomodoro timer living in the system tray. It alternates focused work " &
"sessions with breaks, and uses breaks to remind you to take care of your health " &
"(walk, drink water, stretch…).") & "</p></div>"
b.add "<h2>" & t(lang, "Premiers pas", "Getting started") & "</h2><ul>" &
t(lang,
"<li>Au premier lancement, un assistant vous guide (langue, profil, rappels santé).</li>" &
"<li>L'application démarre minimisée : son icône colorée se trouve dans la zone de notification.</li>" &
"<li>Un clic droit sur l'icône ouvre le menu ; un double-clic démarre ou met en pause.</li>" &
"<li>Le raccourci global <span class=\"kbd\">Ctrl+Alt+P</span> démarre/met en pause sans toucher la souris.</li>",
"<li>On first launch, a wizard guides you (language, profile, health reminders).</li>" &
"<li>The app starts minimized: its colored icon lives in the notification area.</li>" &
"<li>Right-click the icon for the menu; double-click to start or pause.</li>" &
"<li>The global shortcut <span class=\"kbd\">Ctrl+Alt+P</span> starts/pauses without touching the mouse.</li>") &
"</ul>"
b.add "<h2>" & t(lang, "Les périodes", "The periods") & "</h2><p>" &
t(lang,
"Un cycle classique : travail (25 min), pause courte (5 min), et après plusieurs cycles, " &
"une pause longue (15 min). Le nombre de cycles avant la pause longue est configurable, " &
"ainsi que les durées via les profils :",
"A classic cycle: work (25 min), short break (5 min), and after several cycles a long " &
"break (15 min). The number of cycles before the long break is configurable, as are the " &
"durations via the profiles:") & "</p><table><tr><th>" &
t(lang, "Profil", "Profile") & "</th><th>" & t(lang, "Travail", "Work") & "</th><th>" &
t(lang, "Pause courte", "Short break") & "</th><th>" & t(lang, "Pause longue", "Long break") & "</th><th>" &
t(lang, "Cycles", "Cycles") & "</th></tr>" &
"<tr><td>" & t(lang, "Classique", "Classic") & "</td><td>25 min</td><td>5 min</td><td>15 min</td><td>4</td></tr>" &
"<tr><td>Deep Work</td><td>50 min</td><td>10 min</td><td>20 min</td><td>4</td></tr>" &
"<tr><td>Sprint</td><td>15 min</td><td>3 min</td><td>10 min</td><td>6</td></tr></table>" &
"<p>" & t(lang,
"Le profil actif se choisit dans le menu de l'icône (« Profils »).",
"The active profile is chosen from the icon menu (“Profiles”).") & "</p>"
b.add "<h2>" & t(lang, "Pendant une pause", "During a break") & "</h2><ul>" &
t(lang,
"<li>« Sauter la pause » passe directement au travail suivant.</li>" &
"<li>« Prolonger la pause » ajoute 5 minutes.</li>" &
"<li>Les rappels santé défilent dans les notifications pendant les pauses, sans répéter deux fois le même.</li>",
"<li>“Skip break” jumps straight to the next work session.</li>" &
"<li>“Extend break” adds 5 minutes.</li>" &
"<li>Health reminders rotate through notifications during breaks, never repeating the same one twice.</li>") &
"</ul>"
b.add "<h2>" & t(lang, "Rappels santé", "Health reminders") & "</h2><p>" &
t(lang,
"Les rappels sont entièrement modifiables depuis la page Réglages : ajoutez, modifiez, " &
"supprimez ou désactivez vos propres activités. Seuls les rappels activés sont affichés.",
"Reminders are fully editable from the Settings page: add, edit, remove or disable your " &
"own activities. Only enabled reminders are shown.") & "</p>"
b.add "<h2>" & t(lang, "Statistiques et objectif", "Statistics and goal") & "</h2><p>" &
t(lang,
"La page Statistiques affiche vos pomodoros du jour, de la semaine, votre série de jours " &
"consécutifs et le total. Vous pouvez définir un objectif journalier dans les réglages : " &
"une notification vous prévient quand il est atteint. Le menu de l'icône permet aussi " &
"d'exporter l'historique en CSV.",
"The Statistics page shows your pomodoros for today, this week, your streak of consecutive " &
"days and the total. You can set a daily goal in settings: a notification tells you when " &
"it is reached. The icon menu also lets you export the history as CSV.") & "</p>"
b.add "<h2>" & t(lang, "Réglages", "Settings") & "</h2><ul>" &
t(lang,
"<li><b>Durées</b> : valeurs de base du minuteur (si aucun profil ne correspond).</li>" &
"<li><b>Objectif journalier</b> : nombre de pomodoros par jour (0 = aucun).</li>" &
"<li><b>Son</b> : joue un son en fin de période.</li>" &
"<li><b>Ne pas déranger</b> : suspend les rappels santé (le minuteur continue).</li>" &
"<li><b>Démarrage avec Windows</b> : lance NimPulse à l'ouverture de session.</li>" &
"<li><b>Langue</b> : automatique, français ou anglais.</li>" &
"<li><b>Fenêtre flottante</b> : petit panneau toujours au-dessus affichant la phase et le temps restant.</li>",
"<li><b>Durations</b>: base timer values (when no profile matches).</li>" &
"<li><b>Daily goal</b>: pomodoros per day (0 = none).</li>" &
"<li><b>Sound</b>: plays a sound at the end of a period.</li>" &
"<li><b>Do not disturb</b>: suspends health reminders (the timer keeps running).</li>" &
"<li><b>Start with Windows</b>: launches NimPulse at logon.</li>" &
"<li><b>Language</b>: automatic, French or English.</li>" &
"<li><b>Floating window</b>: small always-on-top panel showing the phase and remaining time.</li>") &
"</ul>"
b.add "<h2>" & t(lang, "Astuces", "Tips") & "</h2><ul>" &
t(lang,
"<li>En <b>mode portable</b> (fichier vide <code>portable</code> à côté de l'exécutable), " &
"la configuration et les statistiques sont stockées au même endroit que l'application — pratique sur clé USB.</li>" &
"<li>Lancer NimPulse une seconde fois ne crée pas de doublon (mono-instance).</li>" &
"<li>Ces pages web (aide, statistiques, réglages) sont servies localement par l'application : " &
"aucune donnée ne quitte votre ordinateur.</li>",
"<li>In <b>portable mode</b> (empty file <code>portable</code> next to the executable), " &
"configuration and statistics are stored next to the app — handy on USB sticks.</li>" &
"<li>Launching NimPulse a second time does not create a duplicate (single instance).</li>" &
"<li>These web pages (help, statistics, settings) are served locally by the app: " &
"no data leaves your computer.</li>") &
"</ul>"
b.add "<h2>" & t(lang, "Raccourcis", "Shortcuts") & "</h2><table>" &
"<tr><th>" & t(lang, "Action", "Action") & "</th><th>" & t(lang, "Raccourci", "Shortcut") & "</th></tr>" &
"<tr><td>" & t(lang, "Démarrer / mettre en pause", "Start / pause") & "</td><td><span class=\"kbd\">Ctrl+Alt+P</span></td></tr>" &
"<tr><td>" & t(lang, "Menu contextuel", "Context menu") & "</td><td>" & t(lang, "Clic droit sur l'icône", "Right-click the icon") & "</td></tr>" &
"<tr><td>" & t(lang, "Démarrer / mettre en pause", "Start / pause") & "</td><td>" & t(lang, "Double-clic", "Double-click") & "</td></tr>" &
"</table>"
pageShell(lang, t(lang, "Aide", "Help"), b)
# ---------------------------------------------------------------------------
# Page statistiques
# ---------------------------------------------------------------------------
proc renderStats*(cfg: Config): string =
let lang = resolveLang(cfg)
let st = loadStatsFrom(statsFile())
let today = st.completedToday()
let week = st.completedThisWeek()
let streak = st.streak()
let total = st.totalPomodoros()
let days = st.lastNDays(7)
var maxv = 1
for (_, n) in days:
if n > maxv: maxv = n
var b = ""
b.add "<h1>" & t(lang, "Statistiques", "Statistics") & "</h1>"
b.add "<div class=\"card\"><h2>" & t(lang, "Aujourd'hui", "Today") & "</h2>" &
"<p style=\"font-size:2.2rem;margin:.2rem 0;font-weight:700\">" & $today & " <span class=\"muted\">" &
t(lang, "pomodoro(s)", "pomodoro(s)") & "</span></p>" &
(if cfg.dailyGoal > 0:
"<p class=\"muted\">" & t(lang, "Objectif", "Goal") & " : " & $today & " / " & $cfg.dailyGoal & "</p>" &
"<div class=\"bar\" style=\"width:100%\"><div style=\"width:" &
$(if today >= cfg.dailyGoal: 100 else: today * 100 div cfg.dailyGoal) & "%\"></div></div>"
else: "") &
"</div>"
b.add "<div class=\"grid\"><div class=\"card\"><h2>" & t(lang, "Cette semaine", "This week") & "</h2>" &
"<p style=\"font-size:1.6rem;margin:.2rem 0;font-weight:700\">" & $week & "</p></div>" &
"<div class=\"card\"><h2>" & t(lang, "Série (streak)", "Streak") & "</h2>" &
"<p style=\"font-size:1.6rem;margin:.2rem 0;font-weight:700\">" & $streak & " <span class=\"muted\">" &
t(lang, "jour(s)", "day(s)") & "</span></p></div></div>"
b.add "<div class=\"card\"><h2>" & t(lang, "Total", "Total") & "</h2>" &
"<p style=\"font-size:1.6rem;margin:.2rem 0;font-weight:700\">" & $total & " <span class=\"muted\">" &
t(lang, "pomodoros complétés", "completed pomodoros") & "</span></p></div>"
b.add "<h2>" & t(lang, "7 derniers jours", "Last 7 days") & "</h2><table>" &
"<tr><th>" & t(lang, "Date", "Date") & "</th><th>" & t(lang, "Pomodoros", "Pomodoros") & "</th></tr>"
for (date, n) in days:
b.add "<tr><td>" & htmlEscape(date) & "</td><td>" & $n & " <span class=\"bar\" style=\"width:120px\"><div style=\"width:" &
$(n * 100 div maxv) & "%\"></div></span></td></tr>"
b.add "</table>"
b.add "<p class=\"muted\">" & t(lang,
"Un export CSV est disponible depuis le menu de l'icône (Exporter les statistiques).",
"A CSV export is available from the icon menu (Export statistics).") & "</p>"
pageShell(lang, t(lang, "Statistiques", "Statistics"), b)
# ---------------------------------------------------------------------------
# Page réglages
# ---------------------------------------------------------------------------
proc remindersJson(rs: seq[HealthReminder]): string =
## Sérialise les rappels en JSON (compact) pour le champ caché / le script.
var items: seq[string]
for r in rs:
items.add "{\"text\":" & $(%r.text) & ",\"enabled\":" & $(%r.enabled) & "}"
"[" & items.join(",") & "]"
proc renderSettings*(cfg: Config): string =
let lang = resolveLang(cfg)
var profileOpts = ""
for p in cfg.profiles:
let sel = if p.name == cfg.activeProfile: " selected" else: ""
profileOpts.add "<option value=\"" & htmlEscape(p.name) & "\"@@" & sel & ">" &
htmlEscape(p.name) & "</option>"
let langOpts =
"<option value=\"auto\"@@" & (if cfg.lang == "auto": " selected" else: "") & ">" &
t(lang, "Automatique", "Automatic") & "</option>" &
"<option value=\"fr\"@@" & (if cfg.lang == "fr": " selected" else: "") & ">Français</option>" &
"<option value=\"en\"@@" & (if cfg.lang == "en": " selected" else: "") & ">English</option>"
var b = ""
b.add "<h1>" & t(lang, "Réglages", "Settings") & "</h1>"
b.add "<form method=\"post\" action=\"/settings\" onsubmit=\"syncReminders()\">"
b.add "<div class=\"grid\"><div>"
b.add "<label>" & t(lang, "Travail (min)", "Work (min)") & "</label>" &
"<input type=\"number\" name=\"workMin\" min=\"1\" value=\"" & $cfg.workMin & "\">"
b.add "<label>" & t(lang, "Pause courte (min)", "Short break (min)") & "</label>" &
"<input type=\"number\" name=\"shortBreakMin\" min=\"1\" value=\"" & $cfg.shortBreakMin & "\">"
b.add "<label>" & t(lang, "Pause longue (min)", "Long break (min)") & "</label>" &
"<input type=\"number\" name=\"longBreakMin\" min=\"1\" value=\"" & $cfg.longBreakMin & "\">"
b.add "<label>" & t(lang, "Cycles avant pause longue", "Cycles before long break") & "</label>" &
"<input type=\"number\" name=\"cyclesBeforeLongBreak\" min=\"1\" value=\"" & $cfg.cyclesBeforeLongBreak & "\">"
b.add "<label>" & t(lang, "Objectif journalier (0 = aucun)", "Daily goal (0 = none)") & "</label>" &
"<input type=\"number\" name=\"dailyGoal\" min=\"0\" value=\"" & $cfg.dailyGoal & "\">"
b.add "</div><div>"
b.add "<label>" & t(lang, "Langue", "Language") & "</label>" &
"<select name=\"lang\">" & langOpts & "</select>"
b.add "<label>" & t(lang, "Profil actif", "Active profile") & "</label>" &
"<select name=\"activeProfile\">" & profileOpts & "</select>"
b.add "<p style=\"margin-top:.9rem\">"
b.add "<label style=\"display:flex;gap:.5rem;align-items:center\"><input type=\"checkbox\" name=\"soundEnabled\"@@" &
(if cfg.soundEnabled: " checked" else: "") & "> " & t(lang, "Son en fin de période", "Sound at end of period") & "</label>"
b.add "<label style=\"display:flex;gap:.5rem;align-items:center\"><input type=\"checkbox\" name=\"doNotDisturb\"@@" &
(if cfg.doNotDisturb: " checked" else: "") & "> " & t(lang, "Ne pas déranger", "Do not disturb") & "</label>"
b.add "<label style=\"display:flex;gap:.5rem;align-items:center\"><input type=\"checkbox\" name=\"autoStart\"@@" &
(if cfg.autoStart: " checked" else: "") & "> " & t(lang, "Démarrage avec Windows", "Start with Windows") & "</label>"
b.add "<label style=\"display:flex;gap:.5rem;align-items:center\"><input type=\"checkbox\" name=\"floatingWindow\"@@" &
(if cfg.floatingWindow: " checked" else: "") & "> " & t(lang, "Fenêtre flottante", "Floating window") & "</label>"
b.add "</p></div></div>"
# --- Rappels santé (éditeur client) ---
b.add "<h2>" & t(lang, "Rappels santé", "Health reminders") & "</h2>"
b.add "<div id=\"reminders\"></div>"
b.add "<button type=\"button\" class=\"btn\" onclick=\"addReminder()\" style=\"margin-top:.6rem\">" &
t(lang, "Ajouter un rappel", "Add a reminder") & "</button>"
b.add "<input type=\"hidden\" name=\"reminders_json\" id=\"reminders_json\">"
b.add "<p style=\"margin-top:1.4rem\"><button type=\"submit\" class=\"btn\">" &
t(lang, "Enregistrer", "Save") & "</button></p>"
b.add "</form>"
b.add "<script>"
b.add "var reminders = " & remindersJson(cfg.healthReminders) & ";"
b.add """var R = function(fr, en){ return document.documentElement.lang === 'fr' ? fr : en; };
function renderReminders(){
var el = document.getElementById('reminders');
if (reminders.length === 0){ el.innerHTML = '<p class="muted">' + R('Aucun rappel.','No reminders.') + '</p>'; return; }
var html = '<table><tr><th></th><th>' + R('Texte','Text') + '</th><th></th><th></th></tr>';
for (var i = 0; i < reminders.length; i++){
var r = reminders[i];
var safe = r.text.replace(/&/g,'&amp;').replace(/</g,'&lt;');
html += '<tr><td><input type="checkbox" ' + (r.enabled ? 'checked' : '') + ' onchange="toggleReminder(' + i + ')"></td>' +
'<td>' + safe + '</td>' +
'<td><button type="button" onclick="editReminder(' + i + ')">' + R('Modifier','Edit') + '</button></td>' +
'<td><button type="button" onclick="removeReminder(' + i + ')">' + R('Supprimer','Remove') + '</button></td></tr>';
}
html += '</table>';
el.innerHTML = html;
}
function addReminder(){
var t = prompt(R('Nouveau rappel santé :','New health reminder:'));
if (t && t.trim().length > 0){ reminders.push({text: t.trim(), enabled: true}); renderReminders(); }
}
function editReminder(i){
var t = prompt(R('Modifier le rappel :','Edit reminder:'), reminders[i].text);
if (t && t.trim().length > 0){ reminders[i].text = t.trim(); renderReminders(); }
}
function removeReminder(i){ reminders.splice(i, 1); renderReminders(); }
function toggleReminder(i){ reminders[i].enabled = !reminders[i].enabled; }
function syncReminders(){ document.getElementById('reminders_json').value = JSON.stringify(reminders); }
renderReminders();
"""
b.add "</script>"
pageShell(lang, t(lang, "Réglages", "Settings"), b)
proc renderSaved(cfg: Config): string =
let lang = resolveLang(cfg)
var b = "<h1>" & t(lang, "Réglages enregistrés", "Settings saved") & " ✓</h1>" &
"<div class=\"card\"><p>" &
t(lang, "Votre configuration a été enregistrée et appliquée.",
"Your configuration has been saved and applied.") &
"</p><p><a class=\"btn\" href=\"/settings\">" & t(lang, "Retour aux réglages", "Back to settings") & "</a> " &
"<a class=\"btn\" href=\"/stats\">" & t(lang, "Voir les statistiques", "View statistics") & "</a></p></div>"
pageShell(lang, t(lang, "Réglages enregistrés", "Settings saved"), b)
# ---------------------------------------------------------------------------
# Application d'un formulaire de réglages
# ---------------------------------------------------------------------------
proc parseIntSafe(s: string, dflt: int): int =
try: result = parseInt(s)
except ValueError: result = dflt
proc applySettings*(cfg: Config, form: Table[string, string]): Config =
## Applique les valeurs d'un formulaire POST à une configuration.
result = cfg
result.workMin = parseIntSafe(form.getOrDefault("workMin"), cfg.workMin)
result.shortBreakMin = parseIntSafe(form.getOrDefault("shortBreakMin"), cfg.shortBreakMin)
result.longBreakMin = parseIntSafe(form.getOrDefault("longBreakMin"), cfg.longBreakMin)
result.cyclesBeforeLongBreak =
parseIntSafe(form.getOrDefault("cyclesBeforeLongBreak"), cfg.cyclesBeforeLongBreak)
result.dailyGoal = parseIntSafe(form.getOrDefault("dailyGoal"), cfg.dailyGoal)
result.soundEnabled = form.hasKey("soundEnabled")
result.doNotDisturb = form.hasKey("doNotDisturb")
result.autoStart = form.hasKey("autoStart")
result.floatingWindow = form.hasKey("floatingWindow")
let lang = form.getOrDefault("lang")
if lang in ["auto", "fr", "en"]:
result.lang = lang
let prof = form.getOrDefault("activeProfile")
for p in result.profiles:
if p.name == prof:
result.activeProfile = prof
break
let remJson = form.getOrDefault("reminders_json")
if remJson.len > 0:
try:
let n = parseJson(remJson)
if n.kind == JArray:
var newRem: seq[HealthReminder]
for r in n:
let text = r.getOrDefault("text").getStr().strip()
if text.len > 0:
newRem.add HealthReminder(text: text,
enabled: r.getOrDefault("enabled").getBool(true))
if newRem.len > 0:
result.healthReminders = newRem
except:
discard
# ---------------------------------------------------------------------------
# Routage
# ---------------------------------------------------------------------------
proc handleRequest(req: HttpRequest): string {.gcsafe.} =
case req.path
of "/", "/help":
httpResponse(renderHelp(loadConfigFrom(configFile())))
of "/stats":
httpResponse(renderStats(loadConfigFrom(configFile())))
of "/settings":
if req.verb == "POST":
let cfg = applySettings(loadConfigFrom(configFile()), parseForm(req.body))
saveConfigTo(cfg, configFile())
markSettingsChanged()
httpResponse(renderSaved(cfg))
else:
httpResponse(renderSettings(loadConfigFrom(configFile())))
of "/favicon.ico":
"HTTP/1.1 204 No Content\r\nConnection: close\r\n\r\n"
else:
httpResponse("<h1>404</h1><p>" &
t(resolveLang(loadConfigFrom(configFile())), "Page introuvable.", "Page not found.") &
"</p>", status = "404 Not Found")
# ---------------------------------------------------------------------------
# Lecture d'une requête sur la socket
# ---------------------------------------------------------------------------
proc setRecvTimeout(client: Socket) {.gcsafe.} =
## Timeout de lecture de 10 s au niveau socket : un client silencieux ne
## bloque pas le thread serveur indéfiniment (recv bloquant simple).
when defined(windows):
const SoRcvTimeout = 0x1006'i32 ## SO_RCVTIMEO (winsock2)
client.getFd().setSockOptInt(SOL_SOCKET, SoRcvTimeout, 10000)
proc readRequest(client: Socket): HttpRequest =
var data = ""
var buf = newString(4096)
while data.len < MaxRequestSize:
var chunk = ""
try:
chunk = client.recv(4096)
except OSError:
break
if chunk.len == 0:
break
data.add chunk
let headerEnd = data.find("\r\n\r\n")
if headerEnd >= 0:
let bodyLen = contentLengthFrom(data[0 ..< headerEnd])
if data.len >= headerEnd + 4 + bodyLen:
break
parseRequest(data)
# ---------------------------------------------------------------------------
# Boucle du serveur (thread dédié)
# ---------------------------------------------------------------------------
proc serverLoop(fd: SocketHandle) {.thread.} =
var server: owned(Socket)
try:
server = newSocket(fd, buffered = false)
except:
running = false
return
server.getFd().setBlocking(false)
while running:
var client: owned(Socket)
try:
server.accept(client)
except OSError:
sleep(100) # aucune connexion en attente : on réessaie
continue
client.getFd().setBlocking(true)
setRecvTimeout(client)
try:
let req = readRequest(client)
let resp = handleRequest(req)
client.send(resp)
except:
discard
try: client.close()
except: discard
# ---------------------------------------------------------------------------
# Démarrage / arrêt
# ---------------------------------------------------------------------------
proc startWebServer*(): int =
## Démarre le serveur sur 127.0.0.1 (premier port libre à partir de
## DefaultPort). Retourne le port choisi, ou 0 en cas d'échec.
if gPort != 0:
return gPort
running = true
for attempt in 0 ..< PortAttempts:
var srv: Socket
try:
srv = newSocket()
srv.setSockOpt(OptReuseAddr, true)
srv.bindAddr(Port(DefaultPort + attempt), "127.0.0.1")
srv.listen()
except:
if srv != nil:
try: srv.close()
except: discard
continue
keepSock = srv
gPort = DefaultPort + attempt
createThread(serverThread, serverLoop, srv.getFd())
return gPort
running = false
return 0
proc stopWebServer*() =
## Arrête le serveur local (attend la fin du thread).
if gPort != 0:
running = false
joinThread(serverThread)
gPort = 0
keepSock = nil
+134
View File
@@ -0,0 +1,134 @@
## test_webserver.nim
## ==================
## Tests du module webserver : parsing HTTP, décodage du formulaire,
## application des réglages, rendu des pages et intégration réelle
## (serveur local 127.0.0.1 interrogé en HTTP).
import std/[unittest, httpclient, os, uri, tables, strutils]
import ../src/webserver
import ../src/config
proc tmpDir(): string =
getAppFilename().parentDir / ".webdata_test"
suite "http helpers":
test "parseRequest parses GET with query":
let req = parseRequest("GET /help?lang=fr HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n")
check req.verb == "GET"
check req.path == "/help"
check req.body == ""
test "parseRequest parses POST with body":
let req = parseRequest(
"POST /settings HTTP/1.1\r\nContent-Length: 10\r\n\r\nworkMin=30")
check req.verb == "POST"
check req.path == "/settings"
check req.body == "workMin=30"
test "parseRequest decodes percent-encoding":
let req = parseRequest("GET /settings%3Fx HTTP/1.1\r\n\r\n")
check req.path == "/settings?x"
test "htmlEscape escapes special characters":
check htmlEscape("<a href=\"x\">&'") == "&lt;a href=&quot;x&quot;&gt;&amp;&#39;"
test "parseForm decodes urlencoded pairs":
let f = parseForm("workMin=30&lang=fr&soundEnabled=on&note=Hello%20world")
check f["workMin"] == "30"
check f["lang"] == "fr"
check f["soundEnabled"] == "on"
check f["note"] == "Hello world"
suite "applySettings":
test "applies a full form":
let base = defaultConfig()
let form = parseForm("workMin=40&shortBreakMin=8&longBreakMin=20&" &
"cyclesBeforeLongBreak=5&dailyGoal=6&lang=en&activeProfile=Sprint&" &
"soundEnabled=on&doNotDisturb=on&" &
"reminders_json=" & encodeUrl("""[{"text":"Boire de l'eau","enabled":true}]"""))
let cfg = applySettings(base, form)
check cfg.workMin == 40
check cfg.shortBreakMin == 8
check cfg.longBreakMin == 20
check cfg.cyclesBeforeLongBreak == 5
check cfg.dailyGoal == 6
check cfg.lang == "en"
check cfg.activeProfile == "Sprint"
check cfg.soundEnabled
check cfg.doNotDisturb
check not cfg.autoStart
check cfg.healthReminders.len == 1
check cfg.healthReminders[0].text == "Boire de l'eau"
test "invalid values fall back to current ones":
let base = defaultConfig()
let form = parseForm("workMin=abc&lang=zz&activeProfile=Inexistant")
let cfg = applySettings(base, form)
check cfg.workMin == base.workMin
check cfg.lang == "auto"
check cfg.activeProfile == base.activeProfile
suite "rendering":
test "pages contain expected content":
var cfg = defaultConfig()
cfg.lang = "fr" # langue fixe : le rendu ne dépend pas du système
check "NimPulse" in renderHelp(cfg)
check "Pomodoro" in renderHelp(cfg)
check "Statistiques" in renderStats(cfg)
check "Réglages" in renderSettings(cfg)
check "name=\"workMin\"" in renderSettings(cfg)
check "reminders_json" in renderSettings(cfg)
suite "integration (local HTTP server)":
test "GET routes work and settings POST persists":
let dir = tmpDir()
removeDir(dir)
createDir(dir)
# Configuration initiale en français : le rendu est déterministe.
var initCfg = defaultConfig()
initCfg.lang = "fr"
saveConfigTo(initCfg, dir / "config.json")
setDataDirOverride(dir)
defer: setDataDirOverride("")
let port = startWebServer()
check port > 0
defer: stopWebServer()
let base = "http://127.0.0.1:" & $port
let client = newHttpClient()
let help = client.getContent(base & "/help")
check "NimPulse" in help
let stats = client.getContent(base & "/stats")
check "Statistiques" in stats
let settings = client.getContent(base & "/settings")
check "name=\"workMin\"" in settings
# Sauvegarde via POST (comme le ferait le navigateur).
let body = "workMin=33&shortBreakMin=6&longBreakMin=18&cyclesBeforeLongBreak=4&" &
"dailyGoal=5&lang=fr&activeProfile=Classique&" &
"reminders_json=" & encodeUrl(
"""[{"text":"Étire-toi","enabled":true},{"text":"Bois de l'eau","enabled":false}]""")
let resp = client.postContent(base & "/settings", body)
check "Réglages enregistrés" in resp
# Le fichier config.json a été écrit dans le répertoire de test.
let saved = loadConfigFrom(dir / "config.json")
check saved.workMin == 33
check saved.dailyGoal == 5
check saved.lang == "fr"
check saved.healthReminders.len == 2
check saved.healthReminders[1].enabled == false
# Le drapeau de rechargement est posé puis consommé.
check consumeSettingsChanged()
check not consumeSettingsChanged()
# La page réglages reflète les nouvelles valeurs.
let settings2 = client.getContent(base & "/settings")
check "value=\"33\"" in settings2
removeDir(dir)