Claude commited on
Commit
6bb0e68
·
unverified ·
1 Parent(s): 5e48c0b

ui(config): brancher save/load au frontend (Phase 4.3 chantier post-rewrite)

Browse files

Avant : ``/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

picarones/interfaces/web/static/web-app.js CHANGED
@@ -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();
picarones/interfaces/web/templates/_view_benchmark.html CHANGED
@@ -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;">
tests/web/test_sprint28_ux_save_compare.py CHANGED
@@ -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={