diff --git a/src/web/app.js b/src/web/app.js index f77268f..76634cd 100644 --- a/src/web/app.js +++ b/src/web/app.js @@ -22,6 +22,7 @@ const trainingError = document.getElementById('trainingError'); const trainingImportText = document.getElementById('trainingImportText'); const trainingImportBtn = document.getElementById('trainingImportBtn'); const trainingExportBtn = document.getElementById('trainingExportBtn'); +const trainingReloadBtn = document.getElementById('trainingReloadBtn'); const trainingAttemptStatus = document.getElementById('trainingAttemptStatus'); const trainingAnnotationText = document.getElementById('trainingAnnotationText'); const trainingSaveAnnotationBtn = document.getElementById('trainingSaveAnnotationBtn'); @@ -425,6 +426,9 @@ function setTrainingError(error) { } function markTrainingLoading() { + if (trainingReloadBtn) { + trainingReloadBtn.disabled = true; + } if (trainingCasesList) { trainingCasesList.setAttribute('aria-busy', 'true'); setText(trainingCasesList, 'Loading case list. Next step: review available cases or import a case package.'); @@ -433,9 +437,13 @@ function markTrainingLoading() { trainingProgress.setAttribute('aria-busy', 'true'); setText(trainingProgress, 'Loading progress. Next step: submit an attempt to update progress.'); } + setTrainingAttemptStatus('Training data loading. Next step: wait for cases and progress, then choose a case or import a package.'); } function markTrainingLoaded() { + if (trainingReloadBtn) { + trainingReloadBtn.disabled = false; + } if (trainingCasesList) trainingCasesList.setAttribute('aria-busy', 'false'); if (trainingProgress) trainingProgress.setAttribute('aria-busy', 'false'); } @@ -638,6 +646,29 @@ function renderTrainingProgress(items) { }); } +function renderTrainingLoadFailure(error) { + if (trainingCasesList) { + clearNode(trainingCasesList); + const empty = document.createElement('div'); + empty.className = 'metric training-empty training-state-error'; + empty.setAttribute('role', 'listitem'); + setText(empty, 'Case list unavailable. Next step: reload training data or import a case package.'); + trainingCasesList.appendChild(empty); + } + if (trainingProgress) { + clearNode(trainingProgress); + const empty = document.createElement('div'); + empty.className = 'metric training-empty training-state-error'; + empty.setAttribute('role', 'listitem'); + setText(empty, 'Progress unavailable. Next step: reload training data, then submit an attempt to update progress.'); + trainingProgress.appendChild(empty); + } + renderTrainingCaseDetail(null); + setSelectedTrainingCase(null); + setTrainingAttemptControls(false); + setTrainingAttemptStatus('Training data request failed. Next step: reload training data or import a case package.'); +} + async function loadTrainingDashboard() { if (!trainingCasesList || !trainingProgress) return; @@ -651,7 +682,9 @@ async function loadTrainingDashboard() { renderTrainingCases(cases.items || []); renderTrainingProgress(progress.items || []); + setTrainingAttemptStatus('Training data loaded. Next step: choose View detail or Start attempt, or import a case package if the list is empty.'); } catch (error) { + renderTrainingLoadFailure(error); setTrainingError(error); } finally { markTrainingLoaded(); @@ -889,6 +922,9 @@ if (trainingImportBtn) { if (trainingExportBtn) { trainingExportBtn.addEventListener('click', exportTrainingCases); } +if (trainingReloadBtn) { + trainingReloadBtn.addEventListener('click', loadTrainingDashboard); +} configureAutoRefresh(); loadTrainingDashboard(); refreshDashboard(); diff --git a/src/web/index.html b/src/web/index.html index 55ee831..3031738 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -70,7 +70,8 @@
  • Progress Review attempt progress after submission.
  • - +

    If a public training request fails, use Reload training data or Import cases, then choose a case.

    +

    Import / Export

    Paste a case package JSON from a trusted export. Next step: import or export cases before choosing a case.

    @@ -81,6 +82,7 @@
    +
    diff --git a/src/web/styles.css b/src/web/styles.css index 88e0ae5..b3c1d1f 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -246,7 +246,7 @@ textarea:focus-visible { } .training-toolbar { display: grid; - grid-template-columns: minmax(260px, 1fr) auto auto; + grid-template-columns: minmax(260px, 1fr) auto auto auto; gap: 10px; align-items: end; } @@ -336,12 +336,16 @@ textarea:focus-visible { padding: 10px 12px; } .training-actions button:disabled, +.training-toolbar button:disabled, .training-selected-action button:disabled, .training-refresh-progress:disabled, .training-case-card button:disabled { cursor: not-allowed; opacity: 0.55; } +.training-state-error { + color: #fecaca; +} .training-refresh-progress { margin-bottom: 10px; } diff --git a/tests/unit/test_training_dashboard_m3.py b/tests/unit/test_training_dashboard_m3.py index f078c24..94da3a5 100644 --- a/tests/unit/test_training_dashboard_m3.py +++ b/tests/unit/test_training_dashboard_m3.py @@ -336,6 +336,49 @@ class TrainingDashboardM3Tests(unittest.TestCase): with self.subTest(expected=expected): self.assertIn(expected, app_js) + def test_training_request_failure_states_offer_reload_and_panel_guidance(self) -> None: + index_html = _read_web_file("index.html") + app_js = _read_web_file("app.js") + styles_css = _read_web_file("styles.css") + parser = _parse_index_html() + attrs_by_id = {attrs["id"]: attrs for _tag, attrs in parser.elements if attrs.get("id")} + + self.assertIn('id="trainingReloadBtn"', index_html) + self.assertEqual(parser.buttons_by_id["trainingReloadBtn"].get("type"), "button") + self.assertEqual( + parser.buttons_by_id["trainingReloadBtn"].get("aria-controls"), + "trainingCasesList trainingProgress trainingAttemptStatus", + ) + self.assertEqual( + parser.buttons_by_id["trainingReloadBtn"].get("aria-describedby"), + "trainingWorkflowHelp trainingErrorHelp", + ) + self.assertEqual(attrs_by_id["trainingError"].get("aria-describedby"), "trainingErrorHelp") + + for expected in ( + "If a public training request fails, use Reload training data or Import cases, then choose a case.", + "Reload training data", + "const trainingReloadBtn = document.getElementById('trainingReloadBtn')", + "function renderTrainingLoadFailure(error)", + "renderTrainingLoadFailure(error)", + "Case list unavailable. Next step: reload training data or import a case package.", + "Progress unavailable. Next step: reload training data, then submit an attempt to update progress.", + "Training data request failed. Next step: reload training data or import a case package.", + "Training data loading. Next step: wait for cases and progress, then choose a case or import a package.", + "trainingReloadBtn.disabled = true", + "trainingReloadBtn.disabled = false", + "trainingReloadBtn.addEventListener('click', loadTrainingDashboard)", + ): + with self.subTest(expected=expected): + self.assertIn(expected, index_html + app_js) + + for expected_css in ( + ".training-toolbar", + "grid-template-columns: minmax(260px, 1fr) auto auto auto;", + ): + with self.subTest(expected_css=expected_css): + self.assertIn(expected_css, styles_css) + def test_training_optional_controls_are_guarded(self) -> None: app_js = _read_web_file("app.js")