Spaces:
Sleeping
ui(config): brancher save/load au frontend (Phase 4.3 chantier post-rewrite)
Browse filesAvant : ``/api/config/save`` + ``/api/config/load`` existaient et étaient
testés côté serveur, mais aucun bouton UI ne les appelait — code zombie
typique post-rewrite signalé par l'audit (chemins UI/API/runner désynchronisés).
Ajouté :
- Boutons "💾 Sauvegarder config" et "📂 Charger config" dans
``_view_benchmark.html`` (à côté du bouton "Lancer le benchmark").
- ``saveConfigToFile()`` : POST ``/api/config/save`` avec l'état UI
courant (corpus_path, competitors, normalization, output_dir, …),
déclenche le download du JSON.
- ``loadConfigFromFile()`` + ``onConfigFileSelected()`` : FileReader
→ POST ``/api/config/load`` (validation + upgrade serveur) →
``_applyConfig()`` restaure l'UI.
- ``renderCompetitors()`` appelé après restauration pour réafficher
la liste composée.
Tests : ``TestConfigSaveLoadUIBindings`` vérifie que (a) le template
expose les bons handlers, (b) le JS définit les 3 fonctions et appelle
les bons endpoints. Aucun test serveur existant cassé (25 passed).
https://claude.ai/code/session_01ArfZ8kcgv7Cyda7VbJVmpn
|
@@ -1166,6 +1166,135 @@ async function deleteUploadedCorpus(corpusId) {
|
|
| 1166 |
} catch(e) {}
|
| 1167 |
}
|
| 1168 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1169 |
// ─── Init ────────────────────────────────────────────────────────────────────
|
| 1170 |
document.addEventListener("DOMContentLoaded", async () => {
|
| 1171 |
loadStatus();
|
|
|
|
| 1166 |
} catch(e) {}
|
| 1167 |
}
|
| 1168 |
|
| 1169 |
+
// ─── Config save / load ──────────────────────────────────────────────────────
|
| 1170 |
+
// Bindings UI pour /api/config/save et /api/config/load (Phase 4.3 du
|
| 1171 |
+
// chantier post-rewrite). Avant ce wiring, les endpoints existaient
|
| 1172 |
+
// côté serveur (avec tests dédiés) mais aucun bouton ne les appelait —
|
| 1173 |
+
// code zombie typique post-rewrite.
|
| 1174 |
+
|
| 1175 |
+
function _gatherCurrentConfig() {
|
| 1176 |
+
/** Sérialise l'état UI courant en dict compatible
|
| 1177 |
+
* ``/api/config/save``. Inclut les compétiteurs composés
|
| 1178 |
+
* (_competitors), les options de normalisation et le profil de
|
| 1179 |
+
* langue rapport. */
|
| 1180 |
+
return {
|
| 1181 |
+
label: document.getElementById("report-name").value || "picarones-config",
|
| 1182 |
+
corpus_path: document.getElementById("corpus-path").value,
|
| 1183 |
+
competitors: _competitors,
|
| 1184 |
+
normalization_profile: document.getElementById("norm-profile").value,
|
| 1185 |
+
char_exclude: document.getElementById("char-exclude").value,
|
| 1186 |
+
output_dir: document.getElementById("output-dir").value,
|
| 1187 |
+
report_name: document.getElementById("report-name").value,
|
| 1188 |
+
};
|
| 1189 |
+
}
|
| 1190 |
+
|
| 1191 |
+
async function saveConfigToFile() {
|
| 1192 |
+
/** POST la config courante à /api/config/save et déclenche le
|
| 1193 |
+
* téléchargement du JSON retourné. */
|
| 1194 |
+
const cfg = _gatherCurrentConfig();
|
| 1195 |
+
try {
|
| 1196 |
+
const r = await fetch("/api/config/save", {
|
| 1197 |
+
method: "POST",
|
| 1198 |
+
headers: {"Content-Type": "application/json"},
|
| 1199 |
+
body: JSON.stringify(cfg),
|
| 1200 |
+
});
|
| 1201 |
+
if (!r.ok) {
|
| 1202 |
+
const detail = await r.text();
|
| 1203 |
+
alert(lang === "fr"
|
| 1204 |
+
? "Erreur sauvegarde config : " + detail
|
| 1205 |
+
: "Save config error: " + detail);
|
| 1206 |
+
return;
|
| 1207 |
+
}
|
| 1208 |
+
const blob = await r.blob();
|
| 1209 |
+
// Reconstitue le filename depuis le header Content-Disposition.
|
| 1210 |
+
const cd = r.headers.get("Content-Disposition") || "";
|
| 1211 |
+
const m = cd.match(/filename="([^"]+)"/);
|
| 1212 |
+
const filename = m ? m[1] : "picarones-config.json";
|
| 1213 |
+
const url = URL.createObjectURL(blob);
|
| 1214 |
+
const a = document.createElement("a");
|
| 1215 |
+
a.href = url;
|
| 1216 |
+
a.download = filename;
|
| 1217 |
+
document.body.appendChild(a);
|
| 1218 |
+
a.click();
|
| 1219 |
+
document.body.removeChild(a);
|
| 1220 |
+
URL.revokeObjectURL(url);
|
| 1221 |
+
} catch (e) {
|
| 1222 |
+
alert(lang === "fr"
|
| 1223 |
+
? "Erreur sauvegarde config : " + e.message
|
| 1224 |
+
: "Save config error: " + e.message);
|
| 1225 |
+
}
|
| 1226 |
+
}
|
| 1227 |
+
|
| 1228 |
+
function loadConfigFromFile() {
|
| 1229 |
+
/** Déclenche le sélecteur de fichier — l'utilisateur choisit un
|
| 1230 |
+
* JSON, ``onConfigFileSelected`` fait le reste. */
|
| 1231 |
+
document.getElementById("config-file-input").click();
|
| 1232 |
+
}
|
| 1233 |
+
|
| 1234 |
+
async function onConfigFileSelected(event) {
|
| 1235 |
+
/** Lit le fichier JSON, POST à /api/config/load pour validation +
|
| 1236 |
+
* upgrade éventuel, puis restaure l'état UI depuis le dict retourné. */
|
| 1237 |
+
const file = event.target.files[0];
|
| 1238 |
+
if (!file) return;
|
| 1239 |
+
// Reset l'input pour permettre un re-chargement du même fichier.
|
| 1240 |
+
event.target.value = "";
|
| 1241 |
+
try {
|
| 1242 |
+
const text = await file.text();
|
| 1243 |
+
let parsed;
|
| 1244 |
+
try {
|
| 1245 |
+
parsed = JSON.parse(text);
|
| 1246 |
+
} catch (e) {
|
| 1247 |
+
alert(lang === "fr"
|
| 1248 |
+
? "Fichier JSON invalide : " + e.message
|
| 1249 |
+
: "Invalid JSON file: " + e.message);
|
| 1250 |
+
return;
|
| 1251 |
+
}
|
| 1252 |
+
const r = await fetch("/api/config/load", {
|
| 1253 |
+
method: "POST",
|
| 1254 |
+
headers: {"Content-Type": "application/json"},
|
| 1255 |
+
body: JSON.stringify(parsed),
|
| 1256 |
+
});
|
| 1257 |
+
if (!r.ok) {
|
| 1258 |
+
const detail = await r.text();
|
| 1259 |
+
alert(lang === "fr"
|
| 1260 |
+
? "Erreur chargement config : " + detail
|
| 1261 |
+
: "Load config error: " + detail);
|
| 1262 |
+
return;
|
| 1263 |
+
}
|
| 1264 |
+
const result = await r.json();
|
| 1265 |
+
_applyConfig(result.config || {});
|
| 1266 |
+
} catch (e) {
|
| 1267 |
+
alert(lang === "fr"
|
| 1268 |
+
? "Erreur chargement config : " + e.message
|
| 1269 |
+
: "Load config error: " + e.message);
|
| 1270 |
+
}
|
| 1271 |
+
}
|
| 1272 |
+
|
| 1273 |
+
function _applyConfig(cfg) {
|
| 1274 |
+
/** Restaure l'état UI depuis un dict de config validé serveur.
|
| 1275 |
+
* Champs inconnus = ignorés silencieusement (responsabilité de
|
| 1276 |
+
* ``filter_config`` côté serveur). */
|
| 1277 |
+
if (typeof cfg.corpus_path === "string") {
|
| 1278 |
+
document.getElementById("corpus-path").value = cfg.corpus_path;
|
| 1279 |
+
}
|
| 1280 |
+
if (typeof cfg.normalization_profile === "string") {
|
| 1281 |
+
document.getElementById("norm-profile").value = cfg.normalization_profile;
|
| 1282 |
+
}
|
| 1283 |
+
if (typeof cfg.char_exclude === "string") {
|
| 1284 |
+
document.getElementById("char-exclude").value = cfg.char_exclude;
|
| 1285 |
+
}
|
| 1286 |
+
if (typeof cfg.output_dir === "string") {
|
| 1287 |
+
document.getElementById("output-dir").value = cfg.output_dir;
|
| 1288 |
+
}
|
| 1289 |
+
if (typeof cfg.report_name === "string") {
|
| 1290 |
+
document.getElementById("report-name").value = cfg.report_name;
|
| 1291 |
+
}
|
| 1292 |
+
if (Array.isArray(cfg.competitors)) {
|
| 1293 |
+
_competitors = cfg.competitors;
|
| 1294 |
+
renderCompetitors();
|
| 1295 |
+
}
|
| 1296 |
+
}
|
| 1297 |
+
|
| 1298 |
// ─── Init ────────────────────────────────────────────────────────────────────
|
| 1299 |
document.addEventListener("DOMContentLoaded", async () => {
|
| 1300 |
loadStatus();
|
|
@@ -174,6 +174,10 @@
|
|
| 174 |
<button class="btn btn-primary" id="start-btn" onclick="startBenchmark()" data-i18n="bench_start">▶ Lancer le benchmark</button>
|
| 175 |
<button class="btn btn-secondary" id="cancel-btn" style="display:none;" onclick="cancelBenchmark()" data-i18n="bench_cancel">✕ Annuler</button>
|
| 176 |
<span id="bench-status-text" style="font-size:12px; color: var(--text-muted);"></span>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
</div>
|
| 178 |
|
| 179 |
<div id="bench-progress-section" style="display:none;">
|
|
|
|
| 174 |
<button class="btn btn-primary" id="start-btn" onclick="startBenchmark()" data-i18n="bench_start">▶ Lancer le benchmark</button>
|
| 175 |
<button class="btn btn-secondary" id="cancel-btn" style="display:none;" onclick="cancelBenchmark()" data-i18n="bench_cancel">✕ Annuler</button>
|
| 176 |
<span id="bench-status-text" style="font-size:12px; color: var(--text-muted);"></span>
|
| 177 |
+
<span style="flex:1;"></span>
|
| 178 |
+
<button class="btn btn-secondary btn-sm" id="config-save-btn" onclick="saveConfigToFile()" title="Télécharger la configuration courante en JSON" data-i18n="bench_config_save">💾 Sauvegarder config</button>
|
| 179 |
+
<button class="btn btn-secondary btn-sm" id="config-load-btn" onclick="loadConfigFromFile()" title="Charger une configuration depuis un fichier JSON" data-i18n="bench_config_load">📂 Charger config</button>
|
| 180 |
+
<input type="file" id="config-file-input" accept=".json" style="display:none;" onchange="onConfigFileSelected(event)" />
|
| 181 |
</div>
|
| 182 |
|
| 183 |
<div id="bench-progress-section" style="display:none;">
|
|
@@ -190,6 +190,52 @@ def client():
|
|
| 190 |
return TestClient(app)
|
| 191 |
|
| 192 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
class TestConfigSaveLoad:
|
| 194 |
def test_save_returns_attachment(self, client):
|
| 195 |
r = client.post("/api/config/save", json={
|
|
|
|
| 190 |
return TestClient(app)
|
| 191 |
|
| 192 |
|
| 193 |
+
class TestConfigSaveLoadUIBindings:
|
| 194 |
+
"""Phase 4.3 du chantier post-rewrite : ``saveConfigToFile`` et
|
| 195 |
+
``loadConfigFromFile`` sont désormais branchés au HTML +
|
| 196 |
+
``/api/config/save`` et ``/api/config/load`` (avant : endpoints
|
| 197 |
+
fonctionnels mais aucun bouton UI ne les appelait — code zombie)."""
|
| 198 |
+
|
| 199 |
+
def test_template_exposes_save_load_buttons(self) -> None:
|
| 200 |
+
from pathlib import Path
|
| 201 |
+
|
| 202 |
+
tmpl = (
|
| 203 |
+
Path(__file__).resolve().parents[2]
|
| 204 |
+
/ "picarones/interfaces/web/templates/_view_benchmark.html"
|
| 205 |
+
)
|
| 206 |
+
html = tmpl.read_text(encoding="utf-8")
|
| 207 |
+
assert "saveConfigToFile()" in html, (
|
| 208 |
+
"Le bouton 'Sauvegarder config' doit appeler "
|
| 209 |
+
"saveConfigToFile() dans _view_benchmark.html"
|
| 210 |
+
)
|
| 211 |
+
assert "loadConfigFromFile()" in html, (
|
| 212 |
+
"Le bouton 'Charger config' doit appeler "
|
| 213 |
+
"loadConfigFromFile() dans _view_benchmark.html"
|
| 214 |
+
)
|
| 215 |
+
assert "config-file-input" in html, (
|
| 216 |
+
"Un input file caché ``config-file-input`` doit servir de "
|
| 217 |
+
"déclencheur pour ``onConfigFileSelected``"
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
def test_js_defines_save_and_load_functions(self) -> None:
|
| 221 |
+
from pathlib import Path
|
| 222 |
+
|
| 223 |
+
js = (
|
| 224 |
+
Path(__file__).resolve().parents[2]
|
| 225 |
+
/ "picarones/interfaces/web/static/web-app.js"
|
| 226 |
+
)
|
| 227 |
+
src = js.read_text(encoding="utf-8")
|
| 228 |
+
assert "function saveConfigToFile" in src or "async function saveConfigToFile" in src
|
| 229 |
+
assert "function loadConfigFromFile" in src
|
| 230 |
+
assert "function onConfigFileSelected" in src or "async function onConfigFileSelected" in src
|
| 231 |
+
assert '"/api/config/save"' in src, (
|
| 232 |
+
"saveConfigToFile doit appeler /api/config/save"
|
| 233 |
+
)
|
| 234 |
+
assert '"/api/config/load"' in src, (
|
| 235 |
+
"onConfigFileSelected doit appeler /api/config/load"
|
| 236 |
+
)
|
| 237 |
+
|
| 238 |
+
|
| 239 |
class TestConfigSaveLoad:
|
| 240 |
def test_save_returns_attachment(self, client):
|
| 241 |
r = client.post("/api/config/save", json={
|