feat(web): éditeur de périodes modifiables dans les réglages
La page Réglages permet maintenant de créer, modifier et supprimer ses propres périodes (profils) : nom, travail, pause courte, pause longue et nombre de cycles, avec bouton radio ★ pour choisir la période active. Enregistrement via profiles_json + active_profile (index). Repli automatique sur la première période si l'active est supprimée ; noms vides/doublons ignorés. La page Aide affiche le tableau des périodes réelles de la configuration. Tests : parsing, édition, repli du profil actif et intégration HTTP.
This commit is contained in:
@@ -182,8 +182,9 @@ des pages HTML **servies localement** par l'application elle-même :
|
|||||||
- **Statistiques** affiche les pomodoros du jour / semaine, la série de jours
|
- **Statistiques** affiche les pomodoros du jour / semaine, la série de jours
|
||||||
consécutifs, le total et l'historique des 7 derniers jours.
|
consécutifs, le total et l'historique des 7 derniers jours.
|
||||||
- **Réglages** présente un formulaire complet (durées, objectif, son, langue,
|
- **Réglages** présente un formulaire complet (durées, objectif, son, langue,
|
||||||
profil actif, rappels santé) : l'enregistrement est appliqué immédiatement
|
rappels santé) avec un **éditeur de périodes** : créez, modifiez ou supprimez
|
||||||
par l'application.
|
vos propres profils (nom, travail, pauses, cycles) et choisissez la période
|
||||||
|
active. L'enregistrement est appliqué immédiatement par l'application.
|
||||||
|
|
||||||
L'application lance un petit serveur sur `127.0.0.1` (premier port libre à
|
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
|
partir de `38943`), uniquement accessible en local. Aucune donnée ne quitte
|
||||||
|
|||||||
+142
-30
@@ -256,6 +256,16 @@ input[type=number]:focus,select:focus{outline:none;border-color:#14b8a6;box-shad
|
|||||||
.mini:hover{background:#f0fdfa;border-color:#5eead4}
|
.mini:hover{background:#f0fdfa;border-color:#5eead4}
|
||||||
.mini.danger{color:#b91c1c}
|
.mini.danger{color:#b91c1c}
|
||||||
.mini.danger:hover{background:#fef2f2;border-color:#fca5a5}
|
.mini.danger:hover{background:#fef2f2;border-color:#fca5a5}
|
||||||
|
.proflist{display:flex;flex-direction:column;gap:.8rem}
|
||||||
|
.profilrow{background:#fff;border:1px solid #e2e8f0;border-radius:14px;padding:.9rem 1rem;box-shadow:0 2px 8px rgba(15,118,110,.05)}
|
||||||
|
.profilrow.active{border-color:#5eead4;box-shadow:0 0 0 2px rgba(20,184,166,.15)}
|
||||||
|
.phead{display:flex;align-items:center;gap:.7rem;margin-bottom:.6rem}
|
||||||
|
.pname{flex:1;min-width:0;padding:.45rem .6rem;border:1.5px solid #cbd5e1;border-radius:9px;font-size:.98rem;font-family:inherit;color:#1e293b;background:#fff}
|
||||||
|
.pfields{display:grid;grid-template-columns:repeat(auto-fit,minmax(130px,1fr));gap:.6rem}
|
||||||
|
.pcell{display:flex;flex-direction:column;gap:.25rem;font-size:.85rem;font-weight:600;color:#64748b}
|
||||||
|
.pcell input{width:100%;padding:.4rem .5rem;border:1.5px solid #cbd5e1;border-radius:9px;font-size:.95rem;font-family:inherit;color:#1e293b;background:#fff}
|
||||||
|
.pcell input:focus,.pname:focus{outline:none;border-color:#14b8a6;box-shadow:0 0 0 3px rgba(20,184,166,.18)}
|
||||||
|
.activepick{display:flex;align-items:center;gap:.35rem;font-weight:700;color:#0f766e;cursor:pointer;white-space:nowrap}
|
||||||
.saved{text-align:center;padding:2.5rem 1.5rem}
|
.saved{text-align:center;padding:2.5rem 1.5rem}
|
||||||
.savedemoji{font-size:3rem;margin-bottom:.4rem}
|
.savedemoji{font-size:3rem;margin-bottom:.4rem}
|
||||||
footer{margin-top:2.8rem;padding-top:1.2rem;border-top:1px solid #e2e8f0;color:#94a3b8;font-size:.9rem;text-align:center}
|
footer{margin-top:2.8rem;padding-top:1.2rem;border-top:1px solid #e2e8f0;color:#94a3b8;font-size:.9rem;text-align:center}
|
||||||
@@ -281,6 +291,9 @@ footer{margin-top:2.8rem;padding-top:1.2rem;border-top:1px solid #e2e8f0;color:#
|
|||||||
.progress{background:#1e293b}
|
.progress{background:#1e293b}
|
||||||
.empty{color:#94a3b8;border-color:#334155}
|
.empty{color:#94a3b8;border-color:#334155}
|
||||||
.remrow.off{opacity:.55}
|
.remrow.off{opacity:.55}
|
||||||
|
.profilrow{background:#0f172a;border-color:#1e293b}
|
||||||
|
.pname,.pcell input{background:#0b1220;color:#e2e8f0;border-color:#334155}
|
||||||
|
.activepick{color:#5eead4}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
@@ -333,17 +346,19 @@ proc renderHelp*(cfg: Config): string =
|
|||||||
|
|
||||||
b.add "<h2>⏱️ " & t(lang, "Les périodes", "The periods") & "</h2>"
|
b.add "<h2>⏱️ " & t(lang, "Les périodes", "The periods") & "</h2>"
|
||||||
b.add "<div class='card'><p>" & t(lang,
|
b.add "<div class='card'><p>" & t(lang,
|
||||||
"Un cycle classique : travail (25 min), pause courte (5 min) puis, après plusieurs cycles, une pause longue (15 min). Tout est configurable via les profils :",
|
"Un cycle enchaîne travail, pause courte puis, après plusieurs cycles, une pause longue. Créez et modifiez vos propres périodes depuis la page ⚙️ Réglages :",
|
||||||
"A classic cycle: work (25 min), short break (5 min) then, after several cycles, a long break (15 min). Everything is configurable via the profiles:") & "</p>"
|
"A cycle chains work, short break then, after several cycles, a long break. Create and edit your own profiles from the ⚙️ Settings page:") & "</p>"
|
||||||
b.add "<table><tr><th>👤 " & t(lang, "Profil", "Profile") & "</th><th>💼 " & t(lang, "Travail", "Work") &
|
b.add "<table><tr><th>⭐ " & t(lang, "Active", "Active") & "</th><th>👤 " & t(lang, "Période", "Profile") &
|
||||||
"</th><th>☕ " & t(lang, "Pause courte", "Short break") & "</th><th>🛋️ " & t(lang, "Pause longue", "Long break") &
|
"</th><th>💼 " & t(lang, "Travail", "Work") & "</th><th>☕ " & t(lang, "Pause courte", "Short break") &
|
||||||
"</th><th>🔁 " & t(lang, "Cycles", "Cycles") & "</th></tr>"
|
"</th><th>🛋️ " & t(lang, "Pause longue", "Long break") & "</th><th>🔁 " & t(lang, "Cycles", "Cycles") & "</th></tr>"
|
||||||
b.add "<tr><td>" & t(lang, "Classique", "Classic") & "</td><td>25 min</td><td>5 min</td><td>15 min</td><td>4</td></tr>"
|
for p in cfg.profiles:
|
||||||
b.add "<tr><td>Deep Work</td><td>50 min</td><td>10 min</td><td>20 min</td><td>4</td></tr>"
|
let star = if p.name == cfg.activeProfile: "⭐" else: ""
|
||||||
b.add "<tr><td>Sprint</td><td>15 min</td><td>3 min</td><td>10 min</td><td>6</td></tr></table>"
|
b.add "<tr><td>" & star & "</td><td><b>" & htmlEscape(p.name) & "</b></td><td>" & $p.workMin &
|
||||||
|
"</td><td>" & $p.shortBreakMin & "</td><td>" & $p.longBreakMin & "</td><td>" & $p.cycles & "</td></tr>"
|
||||||
|
b.add "</table>"
|
||||||
b.add "<p class='hint'>" & t(lang,
|
b.add "<p class='hint'>" & t(lang,
|
||||||
"Le profil actif se choisit dans le menu de l'icône (« Profils »).",
|
"La période active est choisie dans le menu de l'icône (« Profils ») ou dans les réglages.",
|
||||||
"The active profile is chosen from the icon menu (“Profiles”).") & "</p></div>"
|
"The active profile is chosen from the icon menu (“Profiles”) or in settings.") & "</p></div>"
|
||||||
|
|
||||||
b.add "<h2>☕ " & t(lang, "Pendant une pause", "During a break") & "</h2>"
|
b.add "<h2>☕ " & t(lang, "Pendant une pause", "During a break") & "</h2>"
|
||||||
b.add "<div class='card'><ul class='clean'>"
|
b.add "<div class='card'><ul class='clean'>"
|
||||||
@@ -474,14 +489,24 @@ proc remindersJson(rs: seq[HealthReminder]): string =
|
|||||||
items.add "{\"text\":" & $(%r.text) & ",\"enabled\":" & $(%r.enabled) & "}"
|
items.add "{\"text\":" & $(%r.text) & ",\"enabled\":" & $(%r.enabled) & "}"
|
||||||
"[" & items.join(",") & "]"
|
"[" & items.join(",") & "]"
|
||||||
|
|
||||||
|
proc profilesJson(ps: seq[Profile]): string =
|
||||||
|
## Sérialise les périodes (profils) en JSON (compact) pour l'éditeur.
|
||||||
|
var items: seq[string]
|
||||||
|
for p in ps:
|
||||||
|
items.add "{\"name\":" & $(%p.name) &
|
||||||
|
",\"workMin\":" & $(%p.workMin) &
|
||||||
|
",\"shortBreakMin\":" & $(%p.shortBreakMin) &
|
||||||
|
",\"longBreakMin\":" & $(%p.longBreakMin) &
|
||||||
|
",\"cycles\":" & $(%p.cycles) & "}"
|
||||||
|
"[" & items.join(",") & "]"
|
||||||
|
|
||||||
proc renderSettings*(cfg: Config): string =
|
proc renderSettings*(cfg: Config): string =
|
||||||
let lang = resolveLang(cfg)
|
let lang = resolveLang(cfg)
|
||||||
|
|
||||||
var profileOpts = ""
|
var activeIdx = 0
|
||||||
for p in cfg.profiles:
|
for i, p in cfg.profiles:
|
||||||
let sel = if p.name == cfg.activeProfile: " selected" else: ""
|
if p.name == cfg.activeProfile:
|
||||||
profileOpts.add "<option value='" & htmlEscape(p.name) & "'" & sel & ">" &
|
activeIdx = i
|
||||||
htmlEscape(p.name) & "</option>"
|
|
||||||
|
|
||||||
let langOpts =
|
let langOpts =
|
||||||
"<option value='auto'" & (if cfg.lang == "auto": " selected" else: "") & ">🌐 " &
|
"<option value='auto'" & (if cfg.lang == "auto": " selected" else: "") & ">🌐 " &
|
||||||
@@ -490,12 +515,12 @@ proc renderSettings*(cfg: Config): string =
|
|||||||
"<option value='en'" & (if cfg.lang == "en": " selected" else: "") & ">🇬🇧 English</option>"
|
"<option value='en'" & (if cfg.lang == "en": " selected" else: "") & ">🇬🇧 English</option>"
|
||||||
|
|
||||||
var b = ""
|
var b = ""
|
||||||
b.add "<form method='post' action='/settings' onsubmit='syncReminders()'>"
|
b.add "<form method='post' action='/settings' onsubmit='syncReminders(); syncProfiles()'>"
|
||||||
|
|
||||||
# --- Minuteur ---
|
# --- Minuteur ---
|
||||||
b.add "<div class='card'><h2>⏱️ " & t(lang, "Minuteur", "Timer") & "</h2>"
|
b.add "<div class='card'><h2>⏱️ " & t(lang, "Minuteur", "Timer") & "</h2>"
|
||||||
b.add "<p class='hint'>" & t(lang,
|
b.add "<p class='hint'>" & t(lang,
|
||||||
"Valeurs de base utilisées quand aucun profil ne correspond.",
|
"Valeurs de base utilisées quand aucune période ne correspond.",
|
||||||
"Base values used when no profile matches.") & "</p>"
|
"Base values used when no profile matches.") & "</p>"
|
||||||
b.add "<div class='grid2'>"
|
b.add "<div class='grid2'>"
|
||||||
b.add numField("💼", t(lang, "Travail (min)", "Work (min)"), "workMin", cfg.workMin, 1)
|
b.add numField("💼", t(lang, "Travail (min)", "Work (min)"), "workMin", cfg.workMin, 1)
|
||||||
@@ -514,13 +539,15 @@ proc renderSettings*(cfg: Config): string =
|
|||||||
"<select name='lang'>" & langOpts & "</select></label>"
|
"<select name='lang'>" & langOpts & "</select></label>"
|
||||||
b.add "</div></div>"
|
b.add "</div></div>"
|
||||||
|
|
||||||
# --- Profil actif ---
|
# --- Périodes (éditeur client) ---
|
||||||
b.add "<div class='card'><h2>👤 " & t(lang, "Profil actif", "Active profile") & "</h2>"
|
b.add "<div class='card'><h2>📚 " & t(lang, "Périodes", "Profiles") & "</h2>"
|
||||||
b.add "<p class='hint'>" & t(lang,
|
b.add "<p class='hint'>" & t(lang,
|
||||||
"Détermine les durées utilisées pour les prochaines périodes.",
|
"Créez et modifiez vos périodes : nom, travail, pauses courtes et longues, cycles. Cochez ★ pour choisir la période active.",
|
||||||
"Determines the durations used for the next periods.") & "</p>"
|
"Create and edit your profiles: name, work, short and long breaks, cycles. Check ★ to pick the active profile.") & "</p>"
|
||||||
b.add "<label class='field'><span class='flabel'>📦 " & t(lang, "Profil", "Profile") & "</span>" &
|
b.add "<div id='profiles'></div>"
|
||||||
"<select name='activeProfile'>" & profileOpts & "</select></label>"
|
b.add "<button type='button' class='btn ghost' onclick='addProfile()' style='margin-top:.8rem'>➕ " &
|
||||||
|
t(lang, "Ajouter une période", "Add a profile") & "</button>"
|
||||||
|
b.add "<input type='hidden' name='profiles_json' id='profiles_json'>"
|
||||||
b.add "</div>"
|
b.add "</div>"
|
||||||
|
|
||||||
# --- Notifications et système ---
|
# --- Notifications et système ---
|
||||||
@@ -548,6 +575,8 @@ proc renderSettings*(cfg: Config): string =
|
|||||||
|
|
||||||
b.add "<script>"
|
b.add "<script>"
|
||||||
b.add "var reminders = " & remindersJson(cfg.healthReminders) & ";"
|
b.add "var reminders = " & remindersJson(cfg.healthReminders) & ";"
|
||||||
|
b.add "var profiles = " & profilesJson(cfg.profiles) & ";"
|
||||||
|
b.add "var activeIdx = " & $activeIdx & ";"
|
||||||
b.add """var R = function(fr, en){ return document.documentElement.lang === 'fr' ? fr : en; };
|
b.add """var R = function(fr, en){ return document.documentElement.lang === 'fr' ? fr : en; };
|
||||||
function esc(s){ return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
function esc(s){ return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||||
function renderReminders(){
|
function renderReminders(){
|
||||||
@@ -580,7 +609,57 @@ function editReminder(i){
|
|||||||
function removeReminder(i){ reminders.splice(i, 1); renderReminders(); }
|
function removeReminder(i){ reminders.splice(i, 1); renderReminders(); }
|
||||||
function toggleReminder(i){ reminders[i].enabled = !reminders[i].enabled; }
|
function toggleReminder(i){ reminders[i].enabled = !reminders[i].enabled; }
|
||||||
function syncReminders(){ document.getElementById('reminders_json').value = JSON.stringify(reminders); }
|
function syncReminders(){ document.getElementById('reminders_json').value = JSON.stringify(reminders); }
|
||||||
renderReminders();
|
function renderProfiles(){
|
||||||
|
var el = document.getElementById('profiles');
|
||||||
|
if (profiles.length === 0){
|
||||||
|
el.innerHTML = '<div class="empty">' + R('🌱 Aucune période.','🌱 No profiles yet.') + '</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var html = '<div class="proflist">';
|
||||||
|
for (var i = 0; i < profiles.length; i++){
|
||||||
|
var p = profiles[i];
|
||||||
|
html += '<div class="profilrow' + (i === activeIdx ? ' active' : '') + '">' +
|
||||||
|
'<div class="phead">' +
|
||||||
|
'<label class="activepick"><input type="radio" name="active_profile" value="' + i + '" ' + (i === activeIdx ? 'checked' : '') + ' onchange="setActive(' + i + ')"> <span>' + R('★ Active','★ Active') + '</span></label>' +
|
||||||
|
'<input class="pname" type="text" value="' + esc(p.name) + '" placeholder="' + R('Nom de la période','Profile name') + '" data-key="name" oninput="updateProfile(this)">' +
|
||||||
|
'<button type="button" class="mini danger" onclick="removeProfile(' + i + ')">🗑️</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="pfields">' +
|
||||||
|
'<label class="pcell"><span>💼 ' + R('Travail','Work') + '</span><input type="number" min="1" value="' + p.workMin + '" data-key="workMin" oninput="updateProfile(this)"></label>' +
|
||||||
|
'<label class="pcell"><span>☕ ' + R('Pause courte','Short break') + '</span><input type="number" min="1" value="' + p.shortBreakMin + '" data-key="shortBreakMin" oninput="updateProfile(this)"></label>' +
|
||||||
|
'<label class="pcell"><span>🛋️ ' + R('Pause longue','Long break') + '</span><input type="number" min="1" value="' + p.longBreakMin + '" data-key="longBreakMin" oninput="updateProfile(this)"></label>' +
|
||||||
|
'<label class="pcell"><span>🔁 ' + R('Cycles','Cycles') + '</span><input type="number" min="1" value="' + p.cycles + '" data-key="cycles" oninput="updateProfile(this)"></label>' +
|
||||||
|
'</div></div>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
el.innerHTML = html;
|
||||||
|
}
|
||||||
|
function updateProfile(el){
|
||||||
|
var row = el.closest('.profilrow');
|
||||||
|
var idx = Array.prototype.indexOf.call(document.querySelectorAll('.profilrow'), row);
|
||||||
|
if (idx < 0 || idx >= profiles.length) return;
|
||||||
|
var key = el.getAttribute('data-key');
|
||||||
|
if (key === 'name'){ profiles[idx].name = el.value; }
|
||||||
|
else { profiles[idx][key] = parseInt(el.value) || 1; }
|
||||||
|
}
|
||||||
|
function setActive(i){ activeIdx = i; renderProfiles(); }
|
||||||
|
function addProfile(){
|
||||||
|
var base = R('Nouvelle période','New profile');
|
||||||
|
var name = base;
|
||||||
|
var n = 1;
|
||||||
|
while (profiles.some(function(p){ return p.name === name; })){ n++; name = base + ' ' + n; }
|
||||||
|
profiles.push({name: name, workMin: 25, shortBreakMin: 5, longBreakMin: 15, cycles: 4});
|
||||||
|
renderProfiles();
|
||||||
|
}
|
||||||
|
function removeProfile(i){
|
||||||
|
if (profiles.length <= 1){ alert(R('Au moins une période est requise.','At least one profile is required.')); return; }
|
||||||
|
profiles.splice(i, 1);
|
||||||
|
if (i === activeIdx){ activeIdx = 0; }
|
||||||
|
else if (i < activeIdx){ activeIdx--; }
|
||||||
|
renderProfiles();
|
||||||
|
}
|
||||||
|
function syncProfiles(){ document.getElementById('profiles_json').value = JSON.stringify(profiles); }
|
||||||
|
renderProfiles();
|
||||||
"""
|
"""
|
||||||
b.add "</script>"
|
b.add "</script>"
|
||||||
|
|
||||||
@@ -623,11 +702,47 @@ proc applySettings*(cfg: Config, form: Table[string, string]): Config =
|
|||||||
let lang = form.getOrDefault("lang")
|
let lang = form.getOrDefault("lang")
|
||||||
if lang in ["auto", "fr", "en"]:
|
if lang in ["auto", "fr", "en"]:
|
||||||
result.lang = lang
|
result.lang = lang
|
||||||
let prof = form.getOrDefault("activeProfile")
|
|
||||||
|
# --- Périodes (profils) modifiables ---
|
||||||
|
let profJson = form.getOrDefault("profiles_json")
|
||||||
|
if profJson.len > 0:
|
||||||
|
try:
|
||||||
|
let n = parseJson(profJson)
|
||||||
|
if n.kind == JArray:
|
||||||
|
var newProfiles: seq[Profile]
|
||||||
|
var seen: seq[string]
|
||||||
|
for p in n:
|
||||||
|
let name = p.getOrDefault("name").getStr().strip()
|
||||||
|
if name.len == 0 or name in seen:
|
||||||
|
continue # noms vides ou doublons ignorés
|
||||||
|
seen.add name
|
||||||
|
newProfiles.add Profile(
|
||||||
|
name: name,
|
||||||
|
workMin: p.getOrDefault("workMin").getInt(25),
|
||||||
|
shortBreakMin: p.getOrDefault("shortBreakMin").getInt(5),
|
||||||
|
longBreakMin: p.getOrDefault("longBreakMin").getInt(15),
|
||||||
|
cycles: p.getOrDefault("cycles").getInt(4))
|
||||||
|
if newProfiles.len > 0:
|
||||||
|
result.profiles = newProfiles
|
||||||
|
except:
|
||||||
|
discard
|
||||||
|
|
||||||
|
# --- Profil actif (index envoyé par l'éditeur) ---
|
||||||
|
let activeIdxStr = form.getOrDefault("active_profile")
|
||||||
|
if activeIdxStr.len > 0:
|
||||||
|
let idx = parseIntSafe(activeIdxStr, -1)
|
||||||
|
if idx >= 0 and idx < result.profiles.len:
|
||||||
|
result.activeProfile = result.profiles[idx].name
|
||||||
|
# Sécurité : le profil actif doit exister, sinon premier profil.
|
||||||
|
var activeExists = false
|
||||||
for p in result.profiles:
|
for p in result.profiles:
|
||||||
if p.name == prof:
|
if p.name == result.activeProfile:
|
||||||
result.activeProfile = prof
|
activeExists = true
|
||||||
break
|
break
|
||||||
|
if not activeExists and result.profiles.len > 0:
|
||||||
|
result.activeProfile = result.profiles[0].name
|
||||||
|
|
||||||
|
# --- Rappels santé ---
|
||||||
let remJson = form.getOrDefault("reminders_json")
|
let remJson = form.getOrDefault("reminders_json")
|
||||||
if remJson.len > 0:
|
if remJson.len > 0:
|
||||||
try:
|
try:
|
||||||
@@ -644,9 +759,6 @@ proc applySettings*(cfg: Config, form: Table[string, string]): Config =
|
|||||||
except:
|
except:
|
||||||
discard
|
discard
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Routage
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
proc handleRequest(req: HttpRequest): string {.gcsafe.} =
|
proc handleRequest(req: HttpRequest): string {.gcsafe.} =
|
||||||
case req.path
|
case req.path
|
||||||
|
|||||||
@@ -43,8 +43,11 @@ suite "applySettings":
|
|||||||
test "applies a full form":
|
test "applies a full form":
|
||||||
let base = defaultConfig()
|
let base = defaultConfig()
|
||||||
let form = parseForm("workMin=40&shortBreakMin=8&longBreakMin=20&" &
|
let form = parseForm("workMin=40&shortBreakMin=8&longBreakMin=20&" &
|
||||||
"cyclesBeforeLongBreak=5&dailyGoal=6&lang=en&activeProfile=Sprint&" &
|
"cyclesBeforeLongBreak=5&dailyGoal=6&lang=en&" &
|
||||||
"soundEnabled=on&doNotDisturb=on&" &
|
"active_profile=1&" &
|
||||||
|
"profiles_json=" & encodeUrl(
|
||||||
|
"""[{"name":"Focus","workMin":30,"shortBreakMin":6,"longBreakMin":18,"cycles":4},{"name":"Rapide","workMin":10,"shortBreakMin":2,"longBreakMin":5,"cycles":3}]""") &
|
||||||
|
"&soundEnabled=on&doNotDisturb=on&" &
|
||||||
"reminders_json=" & encodeUrl("""[{"text":"Boire de l'eau","enabled":true}]"""))
|
"reminders_json=" & encodeUrl("""[{"text":"Boire de l'eau","enabled":true}]"""))
|
||||||
let cfg = applySettings(base, form)
|
let cfg = applySettings(base, form)
|
||||||
check cfg.workMin == 40
|
check cfg.workMin == 40
|
||||||
@@ -53,16 +56,43 @@ suite "applySettings":
|
|||||||
check cfg.cyclesBeforeLongBreak == 5
|
check cfg.cyclesBeforeLongBreak == 5
|
||||||
check cfg.dailyGoal == 6
|
check cfg.dailyGoal == 6
|
||||||
check cfg.lang == "en"
|
check cfg.lang == "en"
|
||||||
check cfg.activeProfile == "Sprint"
|
check cfg.profiles.len == 2
|
||||||
|
check cfg.profiles[0].name == "Focus"
|
||||||
|
check cfg.profiles[1].workMin == 10
|
||||||
|
check cfg.activeProfile == "Rapide"
|
||||||
check cfg.soundEnabled
|
check cfg.soundEnabled
|
||||||
check cfg.doNotDisturb
|
check cfg.doNotDisturb
|
||||||
check not cfg.autoStart
|
check not cfg.autoStart
|
||||||
check cfg.healthReminders.len == 1
|
check cfg.healthReminders.len == 1
|
||||||
check cfg.healthReminders[0].text == "Boire de l'eau"
|
check cfg.healthReminders[0].text == "Boire de l'eau"
|
||||||
|
|
||||||
|
test "profiles are edited and active profile falls back safely":
|
||||||
|
let base = defaultConfig()
|
||||||
|
# Deux périodes, active = index 1.
|
||||||
|
let f1 = parseForm("profiles_json=" & encodeUrl(
|
||||||
|
"""[{"name":"A","workMin":25,"shortBreakMin":5,"longBreakMin":15,"cycles":4},{"name":"B","workMin":50,"shortBreakMin":10,"longBreakMin":20,"cycles":5}]""") &
|
||||||
|
"&active_profile=1")
|
||||||
|
let c1 = applySettings(base, f1)
|
||||||
|
check c1.profiles.len == 2
|
||||||
|
check c1.activeProfile == "B"
|
||||||
|
# Index hors limites (période active supprimée) : repli sur la première.
|
||||||
|
let f2 = parseForm("profiles_json=" & encodeUrl(
|
||||||
|
"""[{"name":"C","workMin":20,"shortBreakMin":4,"longBreakMin":10,"cycles":3}]""") &
|
||||||
|
"&active_profile=5")
|
||||||
|
let c2 = applySettings(base, f2)
|
||||||
|
check c2.profiles.len == 1
|
||||||
|
check c2.activeProfile == "C"
|
||||||
|
# Noms vides et doublons ignorés.
|
||||||
|
let f3 = parseForm("profiles_json=" & encodeUrl(
|
||||||
|
"""[{"name":"","workMin":1,"shortBreakMin":1,"longBreakMin":1,"cycles":1},{"name":"D","workMin":33,"shortBreakMin":3,"longBreakMin":9,"cycles":2},{"name":"D","workMin":99,"shortBreakMin":9,"longBreakMin":27,"cycles":9}]"""))
|
||||||
|
let c3 = applySettings(base, f3)
|
||||||
|
check c3.profiles.len == 1
|
||||||
|
check c3.profiles[0].name == "D"
|
||||||
|
check c3.profiles[0].workMin == 33
|
||||||
|
|
||||||
test "invalid values fall back to current ones":
|
test "invalid values fall back to current ones":
|
||||||
let base = defaultConfig()
|
let base = defaultConfig()
|
||||||
let form = parseForm("workMin=abc&lang=zz&activeProfile=Inexistant")
|
let form = parseForm("workMin=abc&lang=zz&active_profile=99")
|
||||||
let cfg = applySettings(base, form)
|
let cfg = applySettings(base, form)
|
||||||
check cfg.workMin == base.workMin
|
check cfg.workMin == base.workMin
|
||||||
check cfg.lang == "auto"
|
check cfg.lang == "auto"
|
||||||
@@ -78,6 +108,8 @@ suite "rendering":
|
|||||||
check "Réglages" in renderSettings(cfg)
|
check "Réglages" in renderSettings(cfg)
|
||||||
check "name='workMin'" in renderSettings(cfg)
|
check "name='workMin'" in renderSettings(cfg)
|
||||||
check "reminders_json" in renderSettings(cfg)
|
check "reminders_json" in renderSettings(cfg)
|
||||||
|
check "profiles_json" in renderSettings(cfg)
|
||||||
|
check "active_profile" in renderSettings(cfg)
|
||||||
|
|
||||||
suite "integration (local HTTP server)":
|
suite "integration (local HTTP server)":
|
||||||
test "GET routes work and settings POST persists":
|
test "GET routes work and settings POST persists":
|
||||||
@@ -109,8 +141,10 @@ suite "integration (local HTTP server)":
|
|||||||
|
|
||||||
# Sauvegarde via POST (comme le ferait le navigateur).
|
# Sauvegarde via POST (comme le ferait le navigateur).
|
||||||
let body = "workMin=33&shortBreakMin=6&longBreakMin=18&cyclesBeforeLongBreak=4&" &
|
let body = "workMin=33&shortBreakMin=6&longBreakMin=18&cyclesBeforeLongBreak=4&" &
|
||||||
"dailyGoal=5&lang=fr&activeProfile=Classique&" &
|
"dailyGoal=5&lang=fr&active_profile=1&" &
|
||||||
"reminders_json=" & encodeUrl(
|
"profiles_json=" & encodeUrl(
|
||||||
|
"""[{"name":"Zen","workMin":35,"shortBreakMin":7,"longBreakMin":20,"cycles":4},{"name":"Bolt","workMin":5,"shortBreakMin":1,"longBreakMin":3,"cycles":2}]""") &
|
||||||
|
"&reminders_json=" & encodeUrl(
|
||||||
"""[{"text":"Étire-toi","enabled":true},{"text":"Bois de l'eau","enabled":false}]""")
|
"""[{"text":"Étire-toi","enabled":true},{"text":"Bois de l'eau","enabled":false}]""")
|
||||||
let resp = client.postContent(base & "/settings", body)
|
let resp = client.postContent(base & "/settings", body)
|
||||||
check "Réglages enregistrés" in resp
|
check "Réglages enregistrés" in resp
|
||||||
@@ -120,6 +154,10 @@ suite "integration (local HTTP server)":
|
|||||||
check saved.workMin == 33
|
check saved.workMin == 33
|
||||||
check saved.dailyGoal == 5
|
check saved.dailyGoal == 5
|
||||||
check saved.lang == "fr"
|
check saved.lang == "fr"
|
||||||
|
check saved.profiles.len == 2
|
||||||
|
check saved.profiles[0].name == "Zen"
|
||||||
|
check saved.profiles[1].workMin == 5
|
||||||
|
check saved.activeProfile == "Bolt"
|
||||||
check saved.healthReminders.len == 2
|
check saved.healthReminders.len == 2
|
||||||
check saved.healthReminders[1].enabled == false
|
check saved.healthReminders[1].enabled == false
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user