TrayTempo v0.2.0 : minuteur Pomodoro Windows en Nim avec interface intuitive

This commit is contained in:
2026-08-14 10:39:21 -04:00
commit 3218972ddf
26 changed files with 3154 additions and 0 deletions
+120
View File
@@ -0,0 +1,120 @@
## test_config.nim
## ================
## Tests d'intégration de config.nim : valeurs par défaut, résolution de profil,
## round-trip JSON (save/load), tolérance aux fichiers corrompus, et
## démarrage automatique via la clé de registre Run.
import std/[unittest, os]
import ../src/config
# Redirige APPDATA vers un répertoire temporaire pour isoler les fichiers.
let tmpApd = getTempDir() / "traytempo_test_apd"
if dirExists(tmpApd):
removeDir(tmpApd)
putEnv("APPDATA", tmpApd)
suite "config":
test "defaultConfig has expected values":
let c = defaultConfig()
check c.workMin == 25
check c.shortBreakMin == 5
check c.longBreakMin == 15
check c.cyclesBeforeLongBreak == 4
check c.soundEnabled == true
check c.doNotDisturb == false
check c.autoStart == false
check c.activeProfile == "Classique"
check c.floatingWindow == false
check c.profiles.len == 3
check c.healthReminders.len == 5
test "getActiveProfile returns the matching profile":
let c = defaultConfig()
let p = c.getActiveProfile()
check p.name == "Classique"
check p.workMin == 25
check p.cycles == 4
test "getActiveProfile falls back to defaults for an unknown name":
var c = defaultConfig()
c.activeProfile = "N'existe pas"
let p = c.getActiveProfile()
check p.name == "Défaut"
check p.workMin == c.workMin
test "saveConfig then loadConfig round-trips (including accents)":
var c = defaultConfig()
c.workMin = 42
c.doNotDisturb = true
c.activeProfile = "Sprint"
saveConfig(c)
let l = loadConfig()
check l.workMin == 42
check l.doNotDisturb == true
check l.activeProfile == "Sprint"
check l.profiles.len == 3
check l.healthReminders.len == 5
# UTF-8 (accents) doit survivre à l'aller-retour JSON.
check l.healthReminders[0].text == "Lève-toi et marche 2 minutes"
test "loadConfig returns defaults on a corrupt file":
ensureConfigDir()
writeFile(configPath(), "{ pas du json valide")
let l = loadConfig()
check l.workMin == 25
check l.profiles.len == 3
test "new fields (lang, dailyGoal, hotkey, sound) round-trip":
var c = defaultConfig()
c.lang = "en"
c.dailyGoal = 8
c.hotkeyModifiers = 0x0003'i32
c.hotkeyVk = 0x50'i32
c.soundName = "SystemExclamation"
c.floatingWindow = true
saveConfig(c)
let l = loadConfig()
check l.lang == "en"
check l.dailyGoal == 8
check l.hotkeyModifiers == 0x0003'i32
check l.hotkeyVk == 0x50'i32
check l.soundName == "SystemExclamation"
check l.floatingWindow == true
test "English defaults localize profiles and reminders":
let c = defaultConfig(langEn)
check c.activeProfile == "Classic"
check c.profiles[0].name == "Classic"
check c.healthReminders[0].text == "Stand up and walk for 2 minutes"
test "loadConfig keeps defaults for missing new fields":
# Un ancien fichier sans les nouveaux champs doit rester lisible.
ensureConfigDir()
writeFile(configPath(), "{\"workMin\": 30}")
let l = loadConfig()
check l.workMin == 30
check l.lang == "auto"
check l.dailyGoal == 0
test "configDir returns exe dir in portable mode":
let exeDir = getAppFilename().parentDir
let marker = exeDir / "portable"
writeFile(marker, "")
check configDir() == exeDir
removeFile(marker)
check configDir() == (getEnv("APPDATA") / "TrayTempo")
test "acquireSingleInstance detects a second lock":
check not acquireSingleInstance("TrayTempo_Test_Mutex_0.2.0")
check acquireSingleInstance("TrayTempo_Test_Mutex_0.2.0")
suite "autostart registry":
test "setAutostart toggles the Run key and cleans up":
let before = isAutostartEnabled()
check setAutostart(true)
check isAutostartEnabled()
check setAutostart(false)
check not isAutostartEnabled()
# Restaure l'état d'origine pour ne rien laisser traîner.
if before:
discard setAutostart(true)
+34
View File
@@ -0,0 +1,34 @@
## test_dialogs.nim
## =================
## Test d'intégration de la construction du panneau de réglages : vérifie que la
## fenêtre et ses contrôles sont réellement créés (sans lancer la boucle modale,
## qui bloquerait). Cela rend observable la création de l'interface, au-delà de
## la simple compilation.
import std/unittest
import winim/lean
import ../src/dialogs
import ../src/config
proc createParent(): HWND =
let hInstance = GetModuleHandleW(nil)
var wc: WNDCLASSW
wc.lpfnWndProc = cast[WNDPROC](DefWindowProcW)
wc.hInstance = hInstance
wc.lpszClassName = L"TrayTempoDialogTestClass"
discard RegisterClass(&wc)
result = CreateWindowExW(0, L"TrayTempoDialogTestClass", L"", WS_OVERLAPPED,
0, 0, 0, 0, 0, 0, hInstance, nil)
suite "dialogs":
test "settings dialog window and controls are built":
let parent = createParent()
check parent != 0
let hwnd = buildSettingsDialog(parent, defaultConfig())
check hwnd != 0
# La fenêtre doit avoir au moins un contrôle enfant (champ, bouton, etc.).
check GetWindow(hwnd, GW_CHILD) != 0
discard DestroyWindow(hwnd)
discard DestroyWindow(parent)
+18
View File
@@ -0,0 +1,18 @@
## test_floating.nim
## ==================
## Test d'intégration de la fenêtre flottante : crée, met à jour puis détruit
## la vraie fenêtre Win32 et vérifie que l'état est cohérent. Ce test réduit le
## fossé entre « compile » et « observé » pour cette fonctionnalité.
import std/unittest
import ../src/floating
suite "floating window":
test "show, update and hide manage a real window":
check not floatingVisible()
showFloating()
check floatingVisible()
# La mise à jour du texte ne doit pas lever d'erreur.
updateFloating("Travail 24:59")
hideFloating()
check not floatingVisible()
+43
View File
@@ -0,0 +1,43 @@
## test_notifications.nim
## ======================
## Tests unitaires de la rotation des rappels santé (logique pure). Aucun
## appel Win32 : on ne teste que nextHealthReminder.
import std/unittest
import ../src/notifications
import ../src/config
suite "health reminder rotation":
test "does not repeat the same reminder twice in a row":
let cfg = defaultConfig()
let a = nextHealthReminder(cfg)
let b = nextHealthReminder(cfg)
check a.len > 0
check b.len > 0
check a != b
test "a single enabled reminder is always returned":
var cfg = defaultConfig()
for i in 0 ..< cfg.healthReminders.len:
cfg.healthReminders[i].enabled = (i == 0)
check nextHealthReminder(cfg) == "Lève-toi et marche 2 minutes"
check nextHealthReminder(cfg) == "Lève-toi et marche 2 minutes"
test "returns empty string when nothing is enabled":
var cfg = defaultConfig()
for i in 0 ..< cfg.healthReminders.len:
cfg.healthReminders[i].enabled = false
check nextHealthReminder(cfg) == ""
test "skips disabled reminders":
var cfg = defaultConfig()
for i in 0 ..< cfg.healthReminders.len:
cfg.healthReminders[i].enabled = (i == 2)
check nextHealthReminder(cfg) == cfg.healthReminders[2].text
test "custom reminder text is returned verbatim":
var cfg = defaultConfig()
for i in 0 ..< cfg.healthReminders.len:
cfg.healthReminders[i].enabled = false
cfg.healthReminders.add HealthReminder(text: "Médite 5 minutes", enabled: true)
check nextHealthReminder(cfg) == "Médite 5 minutes"
+106
View File
@@ -0,0 +1,106 @@
## test_stats.nim
## ==============
## Tests unitaires des statistiques (compteurs jour/semaine et streak).
## Aucune écriture disque : on manipule directement la table `daily`.
import std/unittest
import std/times
import std/tables
import std/os
import std/strutils
import ../src/stats
suite "stats":
test "initStats starts empty":
let st = initStats()
check st.completedToday() == 0
check st.completedThisWeek() == 0
check st.streak() == 0
test "completedToday counts today's pomodoros":
var st = initStats()
st.daily[todayStr()] = 5
check st.completedToday() == 5
test "streak counts consecutive days ending today":
var st = initStats()
let today = now()
for i in 0 ..< 3:
st.daily[(today - i.days).format("yyyy-MM-dd")] = 1
check st.streak() == 3
test "streak starts from yesterday when today has no entry":
var st = initStats()
let today = now()
st.daily[(today - 1.days).format("yyyy-MM-dd")] = 1
st.daily[(today - 2.days).format("yyyy-MM-dd")] = 1
check st.streak() == 2
test "streak stops at the first gap":
var st = initStats()
let today = now()
st.daily[today.format("yyyy-MM-dd")] = 1
st.daily[(today - 1.days).format("yyyy-MM-dd")] = 1
# jour -2 manquant (gap)
st.daily[(today - 3.days).format("yyyy-MM-dd")] = 1
check st.streak() == 2
test "completedThisWeek sums the last 7 days":
var st = initStats()
let today = now()
for i in 0 ..< 7:
st.daily[(today - i.days).format("yyyy-MM-dd")] = 1
check st.completedThisWeek() == 7
test "totalPomodoros sums the whole history":
var st = initStats()
st.daily["2026-01-01"] = 2
st.daily["2026-01-02"] = 3
st.daily["2026-01-03"] = 5
check st.totalPomodoros() == 10
test "goalReached is true only when the goal is exactly met":
var st = initStats()
st.daily[todayStr()] = 8
check st.goalReached(8)
check not st.goalReached(4)
check not st.goalReached(0) # objectif 0 = désactivé
test "lastNDays returns n entries including empty days":
var st = initStats()
let today = now()
st.daily[today.format("yyyy-MM-dd")] = 4
let rows = st.lastNDays(7)
check rows.len == 7
# Le dernier élément correspond à aujourd'hui.
check rows[^1][0] == today.format("yyyy-MM-dd")
check rows[^1][1] == 4
test "exportCsv writes date,count lines":
var st = initStats()
st.daily["2026-08-13"] = 3
st.daily["2026-08-14"] = 5
let path = getTempDir() / "traytempo_test_export.csv"
check st.exportCsv(path)
let content = readFile(path)
check content.contains("date,count")
check content.contains("2026-08-13,3")
check content.contains("2026-08-14,5")
removeFile(path)
suite "stats persistence":
test "saveStats then loadStats round-trips":
# Isole l'écriture disque dans un APPDATA temporaire.
let tmpApd = getTempDir() / "traytempo_test_stats"
if dirExists(tmpApd):
removeDir(tmpApd)
putEnv("APPDATA", tmpApd)
var st = initStats()
st.daily["2026-08-13"] = 3
st.daily["2026-08-14"] = 5
saveStats(st)
let loaded = loadStats()
check loaded.daily["2026-08-13"] == 3
check loaded.daily["2026-08-14"] == 5
+144
View File
@@ -0,0 +1,144 @@
## test_timer.nim
## ==============
## Tests unitaires de la machine à états du minuteur (module pur, sans Win32).
import std/unittest
import ../src/timer
import ../src/config
proc testProfile(): Profile =
Profile(name: "Test", workMin: 25, shortBreakMin: 5, longBreakMin: 15, cycles: 4)
suite "timer":
test "initial state is idle":
let t = initTimer()
check t.phase == phIdle
check t.remainingSecs == 0
check t.cycleCount == 0
test "startWork sets working phase and duration":
var t = initTimer()
t.startWork(testProfile())
check t.phase == phWorking
check t.remainingSecs == 25 * 60
test "tick decrements remaining by one second":
var t = initTimer()
t.startWork(testProfile())
let ev = t.tick(testProfile())
check ev == evNone
check t.remainingSecs == 25 * 60 - 1
test "tick does nothing when idle":
var t = initTimer()
check t.tick(testProfile()) == evNone
check t.phase == phIdle
test "work completion transitions to short break (1 cycle)":
var t = initTimer()
let p = Profile(name: "T", workMin: 1, shortBreakMin: 5, longBreakMin: 15, cycles: 4)
t.startWork(p)
var ev = evNone
for i in 0 ..< 60:
ev = t.tick(p)
check ev == evWorkCompleted
check t.phase == phShortBreak
check t.remainingSecs == 5 * 60
check t.cycleCount == 1
test "break completion transitions back to work":
var t = initTimer()
let p = Profile(name: "T", workMin: 25, shortBreakMin: 1, longBreakMin: 15, cycles: 4)
t.startBreak(p, longBreak = false)
var ev = evNone
for i in 0 ..< 60:
ev = t.tick(p)
check ev == evBreakCompleted
check t.phase == phWorking
check t.remainingSecs == 25 * 60
test "cyclesBeforeLongBreak triggers a long break":
var t = initTimer()
let p = Profile(name: "T", workMin: 1, shortBreakMin: 5, longBreakMin: 15, cycles: 4)
t.cycleCount = 3 # la 4e période de travail doit déclencher la pause longue
t.startWork(p)
var ev = evNone
for i in 0 ..< 60:
ev = t.tick(p)
check ev == evWorkCompleted
check t.phase == phLongBreak
check t.remainingSecs == 15 * 60
test "pause preserves the remaining time and resume restores phase":
var t = initTimer()
t.startWork(testProfile())
let before = t.remainingSecs
t.pause()
check t.phase == phPaused
check t.remainingSecs == before
t.resume()
check t.phase == phWorking
test "pause on idle does nothing":
var t = initTimer()
t.pause()
check t.phase == phIdle
test "stop returns to idle":
var t = initTimer()
t.startWork(testProfile())
t.stop()
check t.phase == phIdle
check t.remainingSecs == 0
test "stop resets cycleCount so long break timing stays correct":
var t = initTimer()
let p = Profile(name: "T", workMin: 1, shortBreakMin: 1, longBreakMin: 1, cycles: 4)
t.cycleCount = 3
t.stop()
check t.cycleCount == 0
# Après redémarrage, il faut 4 cycles (pas 1) avant la pause longue.
t.startWork(p)
check t.cycleCount == 0
test "isBreak is true only during breaks":
var t = initTimer()
check not t.isBreak()
t.startWork(testProfile())
check not t.isBreak()
t.startBreak(testProfile(), longBreak = false)
check t.isBreak()
t.startBreak(testProfile(), longBreak = true)
check t.isBreak()
test "skipBreak jumps from break to work":
var t = initTimer()
let p = testProfile()
t.startBreak(p, longBreak = false)
t.skipBreak(p)
check t.phase == phWorking
check t.remainingSecs == p.workMin * 60
test "skipBreak does nothing while working":
var t = initTimer()
t.startWork(testProfile())
t.skipBreak(testProfile())
check t.phase == phWorking
test "extendBreak adds minutes to a break":
var t = initTimer()
t.startBreak(testProfile(), longBreak = false)
let before = t.remainingSecs
t.extendBreak(5)
check t.remainingSecs == before + 5 * 60
test "extendBreak does nothing while working":
var t = initTimer()
t.startWork(testProfile())
t.extendBreak(5)
check t.phase == phWorking
test "formatTime formats minutes and seconds":
check formatTime(0) == "0:00"
check formatTime(65) == "1:05"
check formatTime(3661) == "1:01:01"
+55
View File
@@ -0,0 +1,55 @@
## test_tray_integration.nim
## ==========================
## Test d'intégration Win32 : crée une vraie fenêtre cachée, puis exerce les
## interfaces publiques de `tray.nim` et `notifications.nim` contre les API
## réelles (user32/shell32) en vérifiant leurs codes de retour.
##
## Note : ce test fait brièvement apparaître une icône tray et une notification
## sur le bureau, puis nettoie tout à la fin.
import std/unittest
import winim/lean
import ../src/tray
import ../src/notifications
import ../src/timer
proc createTestWindow(): HWND =
let hInstance = GetModuleHandleW(nil)
var wc: WNDCLASSW
wc.lpfnWndProc = cast[WNDPROC](DefWindowProcW)
wc.hInstance = hInstance
wc.lpszClassName = L"TrayTempoTestClass"
discard RegisterClass(&wc)
result = CreateWindowExW(0, L"TrayTempoTestClass", L"", WS_OVERLAPPED,
0, 0, 0, 0, 0, 0, hInstance, nil)
suite "tray integration":
test "tray icon, dynamic icon, toast and hotkey succeed":
let hwnd = createTestWindow()
check hwnd != 0
# Génération des icônes dynamiques (GDI) pour les 5 états.
for phase in [phIdle, phWorking, phShortBreak, phLongBreak, phPaused]:
check phaseIcon(phase) != 0
# Ajout de l'icône dans le tray (Shell_NotifyIcon NIM_ADD -> bool).
check addTrayIcon(hwnd, phaseIcon(phIdle), "TrayTempo") == true
# Mise à jour de l'icône et du tooltip.
updateTrayIcon(hwnd, phaseIcon(phWorking), "Travail: 25:00")
# Notification (NIF_INFO).
showToast(hwnd, "Test", "Notification d'intégration")
# Raccourci global (RegisterHotKey / UnregisterHotKey). Clé de test peu
# susceptible d'être déjà réservée (Ctrl+Alt+F10).
check RegisterHotKey(hwnd, 1'i32, MOD_CONTROL or MOD_ALT, 0x79) != 0
check UnregisterHotKey(hwnd, 1'i32) != 0
# Son de fin de période (alias système valide -> PlaySound retourne true).
check playSound("SystemAsterisk") == true
# Suppression de l'icône et de la fenêtre.
removeTrayIcon(hwnd)
discard DestroyWindow(hwnd)
+29
View File
@@ -0,0 +1,29 @@
## test_updater.nim
## ==================
## Tests unitaires des fonctions pures du module updater (comparaison de
## versions). Aucun accès réseau.
import std/unittest
import ../src/updater
suite "updater":
test "parseVersion splits dotted numbers":
check parseVersion("0.2.0") == @[0, 2, 0]
check parseVersion("1.10.3") == @[1, 10, 3]
check parseVersion("2") == @[2]
test "parseVersion tolerates non-numeric components":
check parseVersion("v1.2") == @[0, 2]
test "isNewer detects a newer version":
check isNewer("0.2.0", "0.3.0")
check isNewer("0.2.9", "0.2.10")
check isNewer("1.0.0", "1.0.1")
test "isNewer returns false for equal or older versions":
check not isNewer("0.3.0", "0.3.0")
check not isNewer("0.3.0", "0.2.9")
check not isNewer("1.0.0", "0.9.9")
test "fetchLatestVersion returns empty for empty URL":
check fetchLatestVersion("") == ""