From 8915aee4983bf430b552a64a6a193296bed1fa4c Mon Sep 17 00:00:00 2001 From: OthmaneZ05 Date: Wed, 15 Jul 2026 12:44:06 -0400 Subject: [PATCH 1/2] feat(learning): pedagogical validation feedback with pass/fail/error states --- .../components/LearningPanel.test.tsx | 61 +++++- .../learning/components/RoadmapPlayer.tsx | 41 ++-- .../components/StepValidationResults.test.tsx | 109 +++++++++++ .../components/StepValidationResults.tsx | 183 +++++++++++++----- .../src/features/learning/validationStatus.ts | 22 +++ frontend/src/index.css | 5 +- frontend/src/locales/en.json | 10 +- frontend/src/locales/fr.json | 10 +- 8 files changed, 378 insertions(+), 63 deletions(-) create mode 100644 frontend/src/features/learning/components/StepValidationResults.test.tsx create mode 100644 frontend/src/features/learning/validationStatus.ts diff --git a/frontend/src/features/learning/components/LearningPanel.test.tsx b/frontend/src/features/learning/components/LearningPanel.test.tsx index fd70fb5..b984e93 100644 --- a/frontend/src/features/learning/components/LearningPanel.test.tsx +++ b/frontend/src/features/learning/components/LearningPanel.test.tsx @@ -59,6 +59,21 @@ const failResponse: StepValidationResponse = { checkedAt: '2026-07-15T10:00:00.000Z', }; +const passResponse: StepValidationResponse = { + roadmapId: roadmap.id, + stepId: 'create-web-server', + stepPassed: true, + results: [ + { + index: 0, + type: 'container_running', + status: 'pass', + message: 'The container "web" is running.', + }, + ], + checkedAt: '2026-07-15T10:01:00.000Z', +}; + function jsonResponse(ok: boolean, body: unknown): Response { return { ok, json: () => Promise.resolve(body) } as Response; } @@ -149,7 +164,7 @@ describe('LearningPanel', () => { expect(screen.getByText('Step 1 of 2')).toBeInTheDocument(); }); - it('validates the current step and renders the raw results', async () => { + it('validates the current step and renders pedagogical feedback', async () => { vi.stubGlobal('fetch', buildFetchMock({})); render( {}} />); await openRoadmapFromCatalog(); @@ -161,6 +176,50 @@ describe('LearningPanel', () => { screen.getByText('No container named "web" exists in this project yet.') ).toBeInTheDocument(); expect(screen.getByText(/a running container named "web"/)).toBeInTheDocument(); + // The step list reflects the failure, and the raw P-1 rendering is gone. + expect(screen.getByTitle('Failed')).toBeInTheDocument(); + expect(screen.queryByText('[fail]')).not.toBeInTheDocument(); + expect(screen.queryByText('container_running')).not.toBeInTheDocument(); + }); + + it('replaces failure feedback cleanly when a revalidation passes', async () => { + let firstAttempt = true; + vi.stubGlobal( + 'fetch', + buildFetchMock({ + validate: () => { + const body = firstAttempt ? failResponse : passResponse; + firstAttempt = false; + return jsonResponse(true, body); + }, + }) + ); + render( {}} />); + await openRoadmapFromCatalog(); + + fireEvent.click(screen.getByRole('button', { name: 'Validate' })); + expect(await screen.findByText('Not yet — see the results below')).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Validate' })); + expect(await screen.findByText('Step passed')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Next step' })).toBeInTheDocument(); + expect(screen.getByTitle('Passed')).toBeInTheDocument(); + // No artifacts from the failed attempt survive. + expect(screen.queryByText('Not yet — see the results below')).not.toBeInTheDocument(); + expect( + screen.queryByText('No container named "web" exists in this project yet.') + ).not.toBeInTheDocument(); + }); + + it('advances to the next step from the success banner', async () => { + vi.stubGlobal('fetch', buildFetchMock({ validate: () => jsonResponse(true, passResponse) })); + render( {}} />); + await openRoadmapFromCatalog(); + + fireEvent.click(screen.getByRole('button', { name: 'Validate' })); + fireEvent.click(await screen.findByRole('button', { name: 'Next step' })); + + expect(screen.getByText('Step 2 of 2')).toBeInTheDocument(); }); it('shows an understandable error with retry when the backend is unreachable during validation', async () => { diff --git a/frontend/src/features/learning/components/RoadmapPlayer.tsx b/frontend/src/features/learning/components/RoadmapPlayer.tsx index 63cdbea..e7a801d 100644 --- a/frontend/src/features/learning/components/RoadmapPlayer.tsx +++ b/frontend/src/features/learning/components/RoadmapPlayer.tsx @@ -1,7 +1,9 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { ArrowLeft, ChevronLeft, ChevronRight, Globe, Copy, Check } from 'lucide-react'; +import { AlertTriangle, ArrowLeft, CheckCircle2, ChevronLeft, ChevronRight, Globe, Copy, Check, XCircle } from 'lucide-react'; import StepValidationResults from './StepValidationResults'; +import { stepOutcome, type StepOutcome } from '../validationStatus'; +import type { StepValidationResponse } from '../../../shared/types/roadmap'; import type { useLearningPlayer } from '../hooks/useLearningPlayer'; import type { ContainerData } from '../../../shared/types'; import type { NetworkConfig } from '../../../shared/types/network'; @@ -49,6 +51,22 @@ function CodeBlock({ code }: { code: string }) { ); } +const MARKER_PRESETS: Record = { + passed: { icon: CheckCircle2, color: 'var(--color-success)', labelKey: 'learning.player.markerPassed' }, + failed: { icon: XCircle, color: 'var(--color-danger)', labelKey: 'learning.player.markerFailed' }, + error: { icon: AlertTriangle, color: 'var(--color-warning)', labelKey: 'learning.player.markerError' }, +}; + +function StepMarker({ response }: { response: StepValidationResponse }) { + const { t } = useTranslation(); + const { icon: Icon, color, labelKey } = MARKER_PRESETS[stepOutcome(response)]; + return ( + + + + ); +} + function renderInstruction(text: string) { if (!text) return null; @@ -183,16 +201,7 @@ export default function RoadmapPlayer({ > {index + 1}. {step.title} - {result && ( - - {result.stepPassed ? '✓' : '✗'} - - )} + {result && } ); })} @@ -291,7 +300,11 @@ export default function RoadmapPlayer({ )} {resultsByStepId[currentStep.id] && ( - + goToStep(currentStepIndex + 1)} + /> )} ); @@ -356,8 +369,8 @@ const styles: Record = { flex: 1, }, stepMarker: { - fontSize: '12px', - fontWeight: 700, + display: 'flex', + alignItems: 'center', }, currentStep: { display: 'flex', diff --git a/frontend/src/features/learning/components/StepValidationResults.test.tsx b/frontend/src/features/learning/components/StepValidationResults.test.tsx new file mode 100644 index 0000000..cc3600b --- /dev/null +++ b/frontend/src/features/learning/components/StepValidationResults.test.tsx @@ -0,0 +1,109 @@ +import '../../../i18n'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import StepValidationResults from './StepValidationResults'; +import type { StepValidationResponse, ValidatorResult } from '../../../shared/types/roadmap'; + +const passResult: ValidatorResult = { + index: 0, + type: 'container_running', + status: 'pass', + message: 'The container "web" is running.', +}; + +const failResult: ValidatorResult = { + index: 0, + type: 'container_running', + status: 'fail', + message: 'No container named "web" exists in this project yet.', + expected: 'a running container named "web"', + observed: 'no container with that name', +}; + +const errorResult: ValidatorResult = { + index: 1, + type: 'table_exists', + status: 'error', + message: 'Something went wrong while talking to Docker.', + errorCode: 'DOCKER_ERROR', +}; + +function buildResponse(results: ValidatorResult[]): StepValidationResponse { + return { + roadmapId: 'example-first-architecture', + stepId: 'create-web-server', + stepPassed: results.every(result => result.status === 'pass'), + results, + checkedAt: '2026-07-15T10:00:00.000Z', + }; +} + +function renderResults( + response: StepValidationResponse, + { isLastStep = false, onNextStep = vi.fn() } = {} +) { + render(); + return { onNextStep }; +} + +describe('StepValidationResults', () => { + it('renders the success banner with a Next step button that advances', () => { + const { onNextStep } = renderResults(buildResponse([passResult])); + + expect(screen.getByText('Step passed')).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Next step' })); + expect(onNextStep).toHaveBeenCalledOnce(); + }); + + it('celebrates the roadmap on the last step and offers no Next step button', () => { + renderResults(buildResponse([passResult]), { isLastStep: true }); + + expect( + screen.getByText('Step passed — that was the last one. Roadmap complete!') + ).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Next step' })).not.toBeInTheDocument(); + }); + + it('renders a pedagogical failure: banner, message, expected and observed', () => { + renderResults(buildResponse([failResult])); + + expect(screen.getByText('Not yet — see the results below')).toBeInTheDocument(); + expect( + screen.getByText('No container named "web" exists in this project yet.') + ).toBeInTheDocument(); + expect(screen.getByText('a running container named "web"')).toBeInTheDocument(); + expect(screen.getByText('no container with that name')).toBeInTheDocument(); + }); + + it('renders an infrastructure error distinctly, without the raw error code', () => { + renderResults(buildResponse([errorResult])); + + expect( + screen.getByText("Some checks couldn't run — that's on us, not you. Fix the issue below or just try again.") + ).toBeInTheDocument(); + expect(screen.getByText("Check couldn't run")).toBeInTheDocument(); + expect(screen.queryByText('DOCKER_ERROR')).not.toBeInTheDocument(); + }); + + it('lets an error win over a failure in the banner while still listing the failure', () => { + renderResults(buildResponse([failResult, errorResult])); + + expect( + screen.getByText("Some checks couldn't run — that's on us, not you. Fix the issue below or just try again.") + ).toBeInTheDocument(); + expect(screen.queryByText('Not yet — see the results below')).not.toBeInTheDocument(); + expect( + screen.getByText('No container named "web" exists in this project yet.') + ).toBeInTheDocument(); + }); + + it('uses the Docker-specific wording when the daemon is unreachable', () => { + renderResults( + buildResponse([{ ...errorResult, errorCode: 'DOCKER_UNAVAILABLE' }]) + ); + + expect( + screen.getByText("Docker isn't running, so the checks couldn't run. Start Docker and validate again.") + ).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/features/learning/components/StepValidationResults.tsx b/frontend/src/features/learning/components/StepValidationResults.tsx index 2d4061c..cdcb0e0 100644 --- a/frontend/src/features/learning/components/StepValidationResults.tsx +++ b/frontend/src/features/learning/components/StepValidationResults.tsx @@ -1,57 +1,111 @@ import { useTranslation } from 'react-i18next'; -import type { StepValidationResponse, ValidatorResult } from '../../../shared/types/roadmap'; +import { AlertTriangle, CheckCircle2, XCircle } from 'lucide-react'; +import { stepOutcome, STATUS_COLORS } from '../validationStatus'; +import type { StepValidationResponse, ValidatorResult, ValidatorStatus } from '../../../shared/types/roadmap'; interface StepValidationResultsProps { response: StepValidationResponse; + isLastStep: boolean; + onNextStep: () => void; } -// P-1 scope: a deliberately raw rendering of the engine's report. -// P-2 turns this into the real ✓/✗/⚠ pedagogical feedback. -export default function StepValidationResults({ response }: StepValidationResultsProps) { - const { t } = useTranslation(); +const STATUS_ICONS: Record = { + pass: CheckCircle2, + fail: XCircle, + error: AlertTriangle, +}; + +const MARKER_LABEL_KEYS: Record = { + pass: 'learning.player.markerPassed', + fail: 'learning.player.markerFailed', + error: 'learning.player.markerError', +}; +export default function StepValidationResults({ + response, + isLastStep, + onNextStep, +}: StepValidationResultsProps) { return (
-
- {response.stepPassed ? t('learning.player.stepPassed') : t('learning.player.stepFailed')} -
+ {response.results.map(result => ( - + ))}
); } -function ResultBlock({ result }: { result: ValidatorResult }) { +function OutcomeBanner({ response, isLastStep, onNextStep }: StepValidationResultsProps) { + const { t } = useTranslation(); + const outcome = stepOutcome(response); + + if (outcome === 'passed') { + return ( +
+
+ + + {isLastStep ? t('learning.player.roadmapComplete') : t('learning.player.stepPassed')} + +
+ {!isLastStep && ( + + )} +
+ ); + } + + if (outcome === 'error') { + const dockerDown = response.results.some(result => result.errorCode === 'DOCKER_UNAVAILABLE'); + return ( +
+
+ + + {dockerDown ? t('learning.player.stepErrorDocker') : t('learning.player.stepError')} + +
+
+ ); + } + + return ( +
+
+ + {t('learning.player.stepFailed')} +
+
+ ); +} + +function ValidatorResultCard({ result }: { result: ValidatorResult }) { const { t } = useTranslation(); - const statusColor = - result.status === 'pass' - ? 'var(--color-success)' - : result.status === 'fail' - ? 'var(--color-danger)' - : 'var(--color-text-muted)'; + const Icon = STATUS_ICONS[result.status]; + const color = STATUS_COLORS[result.status]; return ( -
-
- [{result.status}] - {result.type} - {result.errorCode && {result.errorCode}} +
+
+ + {result.message}
-
{result.message}
+ {result.status === 'error' && ( + {t('learning.player.checkNotRun')} + )} {result.expected && (
- {t('learning.player.expected')}: {result.expected} + {t('learning.player.expected')}:{' '} + {result.expected}
)} {result.observed && (
- {t('learning.player.observed')}: {result.observed} + {t('learning.player.observed')}:{' '} + {result.observed}
)}
@@ -65,10 +119,49 @@ const styles: Record = { gap: '8px', }, banner: { - fontSize: '13px', + display: 'flex', + flexDirection: 'column', + gap: '10px', + padding: '10px 12px', + borderRadius: '6px', + fontSize: '12px', fontWeight: 700, + lineHeight: 1.5, + }, + bannerPassed: { + border: '1px solid var(--color-success)', + backgroundColor: 'var(--color-success-glow)', + }, + bannerFailed: { + border: '1px solid var(--color-danger)', + backgroundColor: 'var(--color-danger-glow)', + }, + bannerError: { + border: '1px solid var(--color-warning)', + backgroundColor: 'var(--color-warning-glow)', + }, + bannerHeader: { + display: 'flex', + alignItems: 'flex-start', + gap: '6px', + }, + bannerIcon: { + flexShrink: 0, + marginTop: '2px', + }, + nextStepBtn: { + alignSelf: 'flex-start', + padding: '6px 14px', + border: 'none', + borderRadius: '6px', + backgroundColor: 'var(--color-success)', + color: '#FFFFFF', + fontSize: '12px', + fontWeight: 600, + cursor: 'pointer', + fontFamily: 'var(--font-sans)', }, - result: { + card: { border: '1px solid var(--border-color)', borderRadius: '6px', padding: '8px 10px', @@ -76,28 +169,25 @@ const styles: Record = { flexDirection: 'column', gap: '4px', }, - resultHeader: { + cardHeader: { display: 'flex', - alignItems: 'center', + alignItems: 'flex-start', gap: '6px', - fontFamily: 'var(--font-mono)', - fontSize: '11px', - }, - resultStatus: { - fontWeight: 700, - }, - resultType: { - color: 'var(--color-text-secondary)', }, - errorCode: { - color: 'var(--color-text-muted)', - marginLeft: 'auto', + cardIcon: { + flexShrink: 0, + marginTop: '2px', }, - resultMessage: { + cardMessage: { fontSize: '12px', color: 'var(--color-text-primary)', lineHeight: 1.5, }, + checkNotRun: { + fontSize: '11px', + fontWeight: 600, + color: 'var(--color-warning-strong)', + }, detailLine: { fontSize: '11px', color: 'var(--color-text-secondary)', @@ -107,4 +197,7 @@ const styles: Record = { fontWeight: 600, color: 'var(--color-text-muted)', }, + detailValue: { + fontFamily: 'var(--font-mono)', + }, }; diff --git a/frontend/src/features/learning/validationStatus.ts b/frontend/src/features/learning/validationStatus.ts new file mode 100644 index 0000000..f7b9a6a --- /dev/null +++ b/frontend/src/features/learning/validationStatus.ts @@ -0,0 +1,22 @@ +import type { StepValidationResponse, ValidatorStatus } from '../../shared/types/roadmap'; + +export type StepOutcome = 'passed' | 'failed' | 'error'; + +/** + * Aggregates a step's validator results into a single outcome. + * `error` wins over `failed`: when a check could not run, the step's real + * state is unknowable — telling the learner "not yet, fix your work" would + * blame them for an infrastructure problem. Failed checks still render + * individually below the banner, so no pedagogical information is lost. + */ +export function stepOutcome(response: StepValidationResponse): StepOutcome { + if (response.stepPassed) return 'passed'; + if (response.results.some(result => result.status === 'error')) return 'error'; + return 'failed'; +} + +export const STATUS_COLORS: Record = { + pass: 'var(--color-success)', + fail: 'var(--color-danger)', + error: 'var(--color-warning)', +}; diff --git a/frontend/src/index.css b/frontend/src/index.css index 26264cc..b0322fc 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -18,7 +18,10 @@ --color-success-glow: rgba(5, 150, 105, 0.1); --color-danger: #DC2626; --color-danger-glow: rgba(220, 38, 38, 0.1); - + --color-warning: #F59E0B; + --color-warning-glow: rgba(245, 158, 11, 0.1); + --color-warning-strong: #92400E; + --font-sans: 'Plus Jakarta Sans', system-ui, -apple-system, sans-serif; --font-mono: 'JetBrains Mono', monospace; diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index 52ef466..c2b8555 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -38,7 +38,15 @@ "stepPassed": "Step passed", "stepFailed": "Not yet — see the results below", "expected": "Expected", - "observed": "Observed" + "observed": "Observed", + "stepError": "Some checks couldn't run — that's on us, not you. Fix the issue below or just try again.", + "stepErrorDocker": "Docker isn't running, so the checks couldn't run. Start Docker and validate again.", + "nextStep": "Next step", + "roadmapComplete": "Step passed — that was the last one. Roadmap complete!", + "checkNotRun": "Check couldn't run", + "markerPassed": "Passed", + "markerFailed": "Failed", + "markerError": "Check error" } }, "projects": { diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json index 317719a..0825d05 100644 --- a/frontend/src/locales/fr.json +++ b/frontend/src/locales/fr.json @@ -38,7 +38,15 @@ "stepPassed": "Étape validée", "stepFailed": "Pas encore — voir les résultats ci-dessous", "expected": "Attendu", - "observed": "Observé" + "observed": "Observé", + "stepError": "Certaines vérifications n'ont pas pu s'exécuter — ce n'est pas de votre faute. Corrigez le problème ci-dessous ou réessayez simplement.", + "stepErrorDocker": "Docker n'est pas démarré, les vérifications n'ont pas pu s'exécuter. Démarrez Docker puis validez à nouveau.", + "nextStep": "Étape suivante", + "roadmapComplete": "Étape validée — c'était la dernière. Roadmap terminée !", + "checkNotRun": "Vérification impossible", + "markerPassed": "Validée", + "markerFailed": "Échouée", + "markerError": "Erreur de vérification" } }, "projects": { From ae9ca9e512c2d046c30ae3007fbf7fc27c450edd Mon Sep 17 00:00:00 2001 From: OthmaneZ05 Date: Wed, 15 Jul 2026 12:55:03 -0400 Subject: [PATCH 2/2] refactor(learning): centralize status presets and apply review findings --- .../components/LearningPanel.test.tsx | 2 +- .../learning/components/RoadmapPlayer.tsx | 16 +++------- .../components/StepValidationResults.test.tsx | 2 +- .../components/StepValidationResults.tsx | 27 +++++----------- .../src/features/learning/validationStatus.ts | 31 ++++++++++++++++--- frontend/src/locales/en.json | 2 +- frontend/src/locales/fr.json | 2 +- frontend/src/shared/types/roadmap.ts | 6 +++- 8 files changed, 48 insertions(+), 40 deletions(-) diff --git a/frontend/src/features/learning/components/LearningPanel.test.tsx b/frontend/src/features/learning/components/LearningPanel.test.tsx index b984e93..21ca46b 100644 --- a/frontend/src/features/learning/components/LearningPanel.test.tsx +++ b/frontend/src/features/learning/components/LearningPanel.test.tsx @@ -176,7 +176,7 @@ describe('LearningPanel', () => { screen.getByText('No container named "web" exists in this project yet.') ).toBeInTheDocument(); expect(screen.getByText(/a running container named "web"/)).toBeInTheDocument(); - // The step list reflects the failure, and the raw P-1 rendering is gone. + // The step list reflects the failure, and the raw status/type strings are gone. expect(screen.getByTitle('Failed')).toBeInTheDocument(); expect(screen.queryByText('[fail]')).not.toBeInTheDocument(); expect(screen.queryByText('container_running')).not.toBeInTheDocument(); diff --git a/frontend/src/features/learning/components/RoadmapPlayer.tsx b/frontend/src/features/learning/components/RoadmapPlayer.tsx index e7a801d..2ccedd1 100644 --- a/frontend/src/features/learning/components/RoadmapPlayer.tsx +++ b/frontend/src/features/learning/components/RoadmapPlayer.tsx @@ -1,8 +1,8 @@ import { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { AlertTriangle, ArrowLeft, CheckCircle2, ChevronLeft, ChevronRight, Globe, Copy, Check, XCircle } from 'lucide-react'; +import { ArrowLeft, ChevronLeft, ChevronRight, Globe, Copy, Check } from 'lucide-react'; import StepValidationResults from './StepValidationResults'; -import { stepOutcome, type StepOutcome } from '../validationStatus'; +import { outcomePreset } from '../validationStatus'; import type { StepValidationResponse } from '../../../shared/types/roadmap'; import type { useLearningPlayer } from '../hooks/useLearningPlayer'; import type { ContainerData } from '../../../shared/types'; @@ -51,18 +51,12 @@ function CodeBlock({ code }: { code: string }) { ); } -const MARKER_PRESETS: Record = { - passed: { icon: CheckCircle2, color: 'var(--color-success)', labelKey: 'learning.player.markerPassed' }, - failed: { icon: XCircle, color: 'var(--color-danger)', labelKey: 'learning.player.markerFailed' }, - error: { icon: AlertTriangle, color: 'var(--color-warning)', labelKey: 'learning.player.markerError' }, -}; - function StepMarker({ response }: { response: StepValidationResponse }) { const { t } = useTranslation(); - const { icon: Icon, color, labelKey } = MARKER_PRESETS[stepOutcome(response)]; + const { icon: Icon, color, labelKey } = outcomePreset(response); return ( - - + + ); } diff --git a/frontend/src/features/learning/components/StepValidationResults.test.tsx b/frontend/src/features/learning/components/StepValidationResults.test.tsx index cc3600b..6452666 100644 --- a/frontend/src/features/learning/components/StepValidationResults.test.tsx +++ b/frontend/src/features/learning/components/StepValidationResults.test.tsx @@ -103,7 +103,7 @@ describe('StepValidationResults', () => { ); expect( - screen.getByText("Docker isn't running, so the checks couldn't run. Start Docker and validate again.") + screen.getByText("Docker wasn't running, so the checks couldn't run. Start Docker and validate again.") ).toBeInTheDocument(); }); }); diff --git a/frontend/src/features/learning/components/StepValidationResults.tsx b/frontend/src/features/learning/components/StepValidationResults.tsx index cdcb0e0..f972b3e 100644 --- a/frontend/src/features/learning/components/StepValidationResults.tsx +++ b/frontend/src/features/learning/components/StepValidationResults.tsx @@ -1,7 +1,7 @@ import { useTranslation } from 'react-i18next'; import { AlertTriangle, CheckCircle2, XCircle } from 'lucide-react'; -import { stepOutcome, STATUS_COLORS } from '../validationStatus'; -import type { StepValidationResponse, ValidatorResult, ValidatorStatus } from '../../../shared/types/roadmap'; +import { stepOutcome, isDockerUnavailable, STATUS_PRESETS } from '../validationStatus'; +import type { StepValidationResponse, ValidatorResult } from '../../../shared/types/roadmap'; interface StepValidationResultsProps { response: StepValidationResponse; @@ -9,18 +9,6 @@ interface StepValidationResultsProps { onNextStep: () => void; } -const STATUS_ICONS: Record = { - pass: CheckCircle2, - fail: XCircle, - error: AlertTriangle, -}; - -const MARKER_LABEL_KEYS: Record = { - pass: 'learning.player.markerPassed', - fail: 'learning.player.markerFailed', - error: 'learning.player.markerError', -}; - export default function StepValidationResults({ response, isLastStep, @@ -59,7 +47,7 @@ function OutcomeBanner({ response, isLastStep, onNextStep }: StepValidationResul } if (outcome === 'error') { - const dockerDown = response.results.some(result => result.errorCode === 'DOCKER_UNAVAILABLE'); + const dockerDown = isDockerUnavailable(response); return (
@@ -84,25 +72,24 @@ function OutcomeBanner({ response, isLastStep, onNextStep }: StepValidationResul function ValidatorResultCard({ result }: { result: ValidatorResult }) { const { t } = useTranslation(); - const Icon = STATUS_ICONS[result.status]; - const color = STATUS_COLORS[result.status]; + const { icon: Icon, color, labelKey } = STATUS_PRESETS[result.status]; return (
- + {result.message}
{result.status === 'error' && ( {t('learning.player.checkNotRun')} )} - {result.expected && ( + {result.expected != null && (
{t('learning.player.expected')}:{' '} {result.expected}
)} - {result.observed && ( + {result.observed != null && (
{t('learning.player.observed')}:{' '} {result.observed} diff --git a/frontend/src/features/learning/validationStatus.ts b/frontend/src/features/learning/validationStatus.ts index f7b9a6a..e00db7b 100644 --- a/frontend/src/features/learning/validationStatus.ts +++ b/frontend/src/features/learning/validationStatus.ts @@ -1,3 +1,4 @@ +import { AlertTriangle, CheckCircle2, XCircle } from 'lucide-react'; import type { StepValidationResponse, ValidatorStatus } from '../../shared/types/roadmap'; export type StepOutcome = 'passed' | 'failed' | 'error'; @@ -15,8 +16,30 @@ export function stepOutcome(response: StepValidationResponse): StepOutcome { return 'failed'; } -export const STATUS_COLORS: Record = { - pass: 'var(--color-success)', - fail: 'var(--color-danger)', - error: 'var(--color-warning)', +// Matches the backend DockerErrorCode emitted when the daemon is unreachable +// (backend infrastructure/docker/dockerErrors.ts). +const DOCKER_UNAVAILABLE = 'DOCKER_UNAVAILABLE'; + +/** True when a check of this attempt could not run because the Docker daemon was unreachable. */ +export function isDockerUnavailable(response: StepValidationResponse): boolean { + return response.results.some(result => result.errorCode === DOCKER_UNAVAILABLE); +} + +interface StatusPreset { + icon: typeof CheckCircle2; + color: string; + labelKey: string; +} + +/** Single source of each status's visual identity (icon, color, i18n label). */ +export const STATUS_PRESETS: Record = { + pass: { icon: CheckCircle2, color: 'var(--color-success)', labelKey: 'learning.player.markerPassed' }, + fail: { icon: XCircle, color: 'var(--color-danger)', labelKey: 'learning.player.markerFailed' }, + error: { icon: AlertTriangle, color: 'var(--color-warning)', labelKey: 'learning.player.markerError' }, }; + +/** Preset for a step's aggregate outcome — the step-list marker shows one glyph per step. */ +export function outcomePreset(response: StepValidationResponse): StatusPreset { + const status: Record = { passed: 'pass', failed: 'fail', error: 'error' }; + return STATUS_PRESETS[status[stepOutcome(response)]]; +} diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json index c2b8555..aa17e52 100644 --- a/frontend/src/locales/en.json +++ b/frontend/src/locales/en.json @@ -40,7 +40,7 @@ "expected": "Expected", "observed": "Observed", "stepError": "Some checks couldn't run — that's on us, not you. Fix the issue below or just try again.", - "stepErrorDocker": "Docker isn't running, so the checks couldn't run. Start Docker and validate again.", + "stepErrorDocker": "Docker wasn't running, so the checks couldn't run. Start Docker and validate again.", "nextStep": "Next step", "roadmapComplete": "Step passed — that was the last one. Roadmap complete!", "checkNotRun": "Check couldn't run", diff --git a/frontend/src/locales/fr.json b/frontend/src/locales/fr.json index 0825d05..800ea65 100644 --- a/frontend/src/locales/fr.json +++ b/frontend/src/locales/fr.json @@ -40,7 +40,7 @@ "expected": "Attendu", "observed": "Observé", "stepError": "Certaines vérifications n'ont pas pu s'exécuter — ce n'est pas de votre faute. Corrigez le problème ci-dessous ou réessayez simplement.", - "stepErrorDocker": "Docker n'est pas démarré, les vérifications n'ont pas pu s'exécuter. Démarrez Docker puis validez à nouveau.", + "stepErrorDocker": "Docker n'était pas démarré, les vérifications n'ont pas pu s'exécuter. Démarrez Docker puis validez à nouveau.", "nextStep": "Étape suivante", "roadmapComplete": "Étape validée — c'était la dernière. Roadmap terminée !", "checkNotRun": "Vérification impossible", diff --git a/frontend/src/shared/types/roadmap.ts b/frontend/src/shared/types/roadmap.ts index 9e1a09a..bcfa108 100644 --- a/frontend/src/shared/types/roadmap.ts +++ b/frontend/src/shared/types/roadmap.ts @@ -76,7 +76,11 @@ export interface ValidatorResult { type: string; status: ValidatorStatus; message: string; - /** Set iff status is 'error'. Wider than the backend union: the frontend only displays it. */ + /** + * Set iff status is 'error'. Wider than the backend union: the frontend matches + * the codes it knows (see features/learning/validationStatus.ts) and treats the + * rest generically, so new backend codes never break rendering. + */ errorCode?: string; expected?: string; observed?: string;