diff --git a/.Jules/palette.md b/.Jules/palette.md index 692a9e9..a53889c 100644 --- a/.Jules/palette.md +++ b/.Jules/palette.md @@ -111,3 +111,19 @@ ## 2025-06-25 - [Copy-to-Clipboard UX and Immediate Feedback] **Learning:** Adding a copy-to-clipboard feature for key user identifiers (like email or IDs) significantly reduces friction in user workflows. Implementing this with `navigator.clipboard.writeText` and providing immediate visual feedback via a toast notification ('Correo copiado al portapapeles') confirms success. Using icon-only buttons with explicit `aria-label` and `title` ensures the feature is both accessible and intuitive. **Action:** Always provide a copy-to-clipboard option for static text that users frequently need to reuse, and ensure immediate feedback is provided upon action. + +## 2025-01-15 - [Keyboard Efficiency and Discoverability in Flashcards] +**Learning:** For high-repetition tasks like flashcard study, keyboard shortcuts (Space/Enter for flip, 1-4 for rating) dramatically improve user flow and "flow state" immersion. However, shortcuts must be discoverable; adding bracketed visual hints like "[Espacio]" or "[1]" directly to button labels, along with descriptive "aria-label" and "title" attributes, ensures accessibility and lowers the learning curve for power users. +**Action:** Always include keyboard shortcuts for core repetitive loops and provide integrated visual hints for those shortcuts to ensure they aren't "hidden" features. + +## 2025-06-18 - [Standardized Accessibility and Password Visibility in V2 Auth Forms] +**Learning:** In V2 authentication flows, maintaining feature parity between Login, Signup, and Forgot Password pages—specifically regarding password visibility toggles and label-input associations—ensures a predictable and accessible user experience. Missing `id` and `htmlFor` attributes on inputs breaks screen reader navigation even if the layout looks correct. +**Action:** Always audit the full authentication suite (Login, Signup, Recovery) for accessibility parity and ensure interactive password fields consistently offer visibility toggles. + +## 2025-06-17 - [Accessible Progress Indicators and V2 Navigation Polish] +**Learning:** For progress indicators to be truly accessible, they must include `role="progressbar"` along with `aria-valuenow`, `aria-valuemin`, and `aria-valuemax` attributes, allowing screen readers to accurately report status. In side-navigation rails, ensuring decorative icons are marked with `aria-hidden="true"` and the active link has `aria-current="page"` significantly improves screen reader navigation. Additionally, maintaining localization consistency (e.g., 'Inicio' vs 'Home') in new UI versions (V2) is essential for a cohesive user experience. +**Action:** Always implement full ARIA attributes for progress components and audit new navigation structures for both accessibility markers and language consistency. + +## 2025-06-17 - [Accessible Checkboxes and Consistent Prop Spreading] +**Learning:** Enhancing base form components like `CustomCheckbox` with standardized `required` indicators (visual asterisk and `aria-required`) and grid support (`s`, `m`, `l`, `xl`, `offset`) improves both accessibility and developer productivity. Ensuring that `...props` are consistently applied to the inner `input` regardless of the presence of a wrapper `div` maintains a predictable component API. +**Action:** Always provide visual and semantic cues for mandatory fields and ensure consistent prop-spreading behavior in multi-layered components. diff --git a/.github/workflows/node.js.yml b/.github/workflows/node.js.yml index 24a2e72..ccde658 100644 --- a/.github/workflows/node.js.yml +++ b/.github/workflows/node.js.yml @@ -42,3 +42,11 @@ jobs: - name: Run tests run: npm test + + - name: Check for console warnings in test output + run: | + npm test 2>&1 | tee vitest-output.txt + if grep -qE 'not wrapped in act|changing an uncontrolled|does not recognize the.*prop|Received `.+?` for a non-boolean attribute|Invalid DOM property|Each child in a list should have a unique.*key' vitest-output.txt; then + echo "ERROR: Console warnings detected in test output!" + exit 1 + fi diff --git a/AGENTS.md b/AGENTS.md index b0ec578..b031d61 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,3 +8,67 @@ ## GitHub Actions - The CI/CD pipeline is configured to use `npm`. - Ensure `package-lock.json` is always updated and committed when changing dependencies. + +## Testing Standards + +### Console Warning Interceptor +All tests run through a global interceptor in `src/test/setupTests.js` that fails tests on specific console.error/console.warn patterns. This catches test-quality issues before they reach CI. + +**Blocked patterns (console.error):** +| Pattern | Issue | Common Cause | +|---|---|---| +| `not wrapped in act` | Async state update after test ends | API mock resolves after `await waitFor(() => {})`; loading state mock never resolves | +| `changing an uncontrolled` | Input rendered without `onChange` handler | Mock form with initial undefined `value`; component renders without controlled prop | +| `does not recognize the.*prop` | Invalid prop passed to DOM element | Passing `noMargin` or other custom props directly to `
`/` - - {label} - {required && ( - - )} - - + // Construct grid and wrapper classes + const hasGrid = s || m || l || xl || offset; + let finalWrapperClasses = wrapperClassName; + if (hasGrid || wrapperClassName) { + if (!finalWrapperClasses.includes('col')) { + finalWrapperClasses += ' col'; + } + if (s) finalWrapperClasses += ` s${s}`; + if (m) finalWrapperClasses += ` m${m}`; + if (l) finalWrapperClasses += ` l${l}`; + if (xl) finalWrapperClasses += ` xl${xl}`; + if (offset) { + offset.split(' ').forEach(off => { + if (off) finalWrapperClasses += ` offset-${off}`; + }); + } + } + + const helperTextId = helperText ? `${id}-helper` : undefined; + + const checkboxContent = ( + <> + + {helperText && {helperText}} + ); + + if (hasGrid || wrapperClassName) { + return ( +
+ {checkboxContent} +
+ ); + } + + return checkboxContent; }; CustomCheckbox.propTypes = { @@ -69,7 +116,14 @@ CustomCheckbox.propTypes = { labelClassName: PropTypes.string, // For the label element indeterminate: PropTypes.bool, value: PropTypes.string, // HTML value attribute for the checkbox + s: PropTypes.number, + m: PropTypes.number, + l: PropTypes.number, + xl: PropTypes.number, + offset: PropTypes.string, required: PropTypes.bool, + helperText: PropTypes.string, + wrapperClassName: PropTypes.string, }; export default CustomCheckbox; diff --git a/src/components/custom/CustomCheckbox.test.jsx b/src/components/custom/CustomCheckbox.test.jsx index ff4a068..f9eda60 100644 --- a/src/components/custom/CustomCheckbox.test.jsx +++ b/src/components/custom/CustomCheckbox.test.jsx @@ -34,4 +34,85 @@ describe('CustomCheckbox', () => { const checkbox = screen.getByLabelText(/Accept Terms/i); expect(checkbox).toHaveAttribute('aria-required', 'true'); }); + + test('is disabled when the prop is true', () => { + render( {}} />); + const checkbox = screen.getByLabelText(/Accept Terms/i); + expect(checkbox).toBeDisabled(); + }); + + test('applies grid classes and style to wrapper div', () => { + const { container } = render( + {}} + /> + ); + + const wrapper = container.firstChild; + expect(wrapper.tagName).toBe('DIV'); + expect(wrapper).toHaveClass('col'); + expect(wrapper).toHaveClass('s12'); + expect(wrapper).toHaveClass('m6'); + expect(wrapper).toHaveClass('offset-s1'); + expect(wrapper).toHaveStyle('margin-bottom: 1rem'); + expect(wrapper).toHaveAttribute('data-testid', 'outer-wrapper'); + }); + + test('applies style and custom props to label if not wrapped in div', () => { + const { container } = render( + {}} + /> + ); + const label = container.firstChild; + expect(label.tagName).toBe('LABEL'); + expect(label).toHaveStyle('color: rgb(255, 0, 0)'); + expect(label).toHaveAttribute('data-custom', 'test'); + }); + + test('renders helper text and links with aria-describedby', () => { + render( {}} />); + + const helper = screen.getByText('Be careful'); + expect(helper).toBeInTheDocument(); + expect(helper).toHaveClass('helper-text'); + expect(helper).toHaveAttribute('id', 'help-check-helper'); + + const input = screen.getByLabelText(/Help Label/); + expect(input).toHaveAttribute('aria-describedby', 'help-check-helper'); + }); + + test('does not wrap in div if no grid or wrapperClassName provided', () => { + const { container } = render( {}} />); + expect(container.firstChild.tagName).toBe('LABEL'); + }); + + test('wraps in div if wrapperClassName is provided', () => { + const { container } = render( + {}} /> + ); + expect(container.firstChild.tagName).toBe('DIV'); + expect(container.firstChild).toHaveClass('custom-wrap'); + }); + + test('applies wrapperClassName to the wrapper div', () => { + const { container } = render( + {}} /> + ); + const wrapper = container.firstChild; + expect(wrapper).toHaveClass('extra-class'); + expect(wrapper).toHaveClass('col'); + expect(wrapper).toHaveClass('s12'); + }); }); diff --git a/src/components/custom/CustomProgressBar.jsx b/src/components/custom/CustomProgressBar.jsx index c2eb1c4..121330d 100644 --- a/src/components/custom/CustomProgressBar.jsx +++ b/src/components/custom/CustomProgressBar.jsx @@ -18,13 +18,22 @@ const CustomProgressBar = ({ wrapperColor = 'grey lighten-3', className = '', style = EMPTY_STYLE, - height + height, + 'aria-label': ariaLabel }) => { const isIndeterminate = progress === undefined || progress === null; const finalStyle = height ? { ...style, height } : style; return ( -
+
{ + it('renders correctly with progress and ARIA attributes', () => { + render(); + + const progressbar = screen.getByRole('progressbar'); + expect(progressbar).toBeInTheDocument(); + expect(progressbar).toHaveAttribute('aria-label', 'Test Progress'); + expect(progressbar).toHaveAttribute('aria-valuenow', '45'); + expect(progressbar).toHaveAttribute('aria-valuemin', '0'); + expect(progressbar).toHaveAttribute('aria-valuemax', '100'); + + const determinatePart = progressbar.querySelector('.determinate'); + expect(determinatePart).toBeInTheDocument(); + expect(determinatePart).toHaveStyle({ width: '45%' }); + }); + + it('renders indeterminate state when progress is null', () => { + render(); + + const progressbar = screen.getByRole('progressbar'); + expect(progressbar).toBeInTheDocument(); + expect(progressbar).not.toHaveAttribute('aria-valuenow'); + + const indeterminatePart = progressbar.querySelector('.indeterminate'); + expect(indeterminatePart).toBeInTheDocument(); + }); + + it('rounds progress for aria-valuenow', () => { + render(); + + const progressbar = screen.getByRole('progressbar'); + expect(progressbar).toHaveAttribute('aria-valuenow', '68'); + }); +}); diff --git a/src/components/custom/CustomSelect.jsx b/src/components/custom/CustomSelect.jsx index ca02d94..346a140 100644 --- a/src/components/custom/CustomSelect.jsx +++ b/src/components/custom/CustomSelect.jsx @@ -24,6 +24,7 @@ const CustomSelect = ({ placeholder, // Placeholder text for the first disabled option offset, helperText, + noMargin, ...props }) => { const selectRef = useRef(null); diff --git a/src/components/custom/CustomTextarea.test.jsx b/src/components/custom/CustomTextarea.test.jsx index 4a564fc..a0dce2b 100644 --- a/src/components/custom/CustomTextarea.test.jsx +++ b/src/components/custom/CustomTextarea.test.jsx @@ -16,24 +16,24 @@ vi.mock('@materializecss/materialize', () => ({ describe('CustomTextarea', () => { test('renders label correctly', () => { - render(); + render( {}} />); expect(screen.getByLabelText(/My Area/i)).toBeInTheDocument(); }); test('renders helper text when provided', () => { - render(); + render( {}} />); expect(screen.getByText('Helpful info')).toHaveClass('helper-text'); }); test('applies offset class to the wrapper', () => { - const { container } = render(); + const { container } = render( {}} />); const wrapper = container.firstChild; expect(wrapper).toHaveClass('offset-s2'); expect(wrapper).toHaveClass('offset-m1'); }); test('renders prefix icon with aria-hidden', () => { - const { container } = render(); + const { container } = render( {}} />); const icon = container.querySelector('.material-icons.prefix'); expect(icon).toBeInTheDocument(); expect(icon).toHaveAttribute('aria-hidden', 'true'); diff --git a/src/components/facebook/FacebookLoginContainer.jsx b/src/components/facebook/FacebookLoginContainer.jsx index 06145c1..ec87b6e 100644 --- a/src/components/facebook/FacebookLoginContainer.jsx +++ b/src/components/facebook/FacebookLoginContainer.jsx @@ -11,12 +11,16 @@ export default function FacebookLoginContainer() { console.log("FB status change:", response); if (response.status === "connected") { + const authResponse = response.authResponse; + const accessToken = authResponse?.accessToken; + // Retrieve user info from Facebook window.FB.api("/me", { fields: "id,name,email" }, (fbRes) => { const params = { name: fbRes.name, facebook_id: fbRes.id, email: fbRes.email || "no_mail", + facebook_access_token: accessToken, // Send token for server-side verification }; // Persist in localStorage and backend diff --git a/src/pages/Player/MyContributions.jsx b/src/pages/Player/MyContributions.jsx index 84072c9..484ba83 100644 --- a/src/pages/Player/MyContributions.jsx +++ b/src/pages/Player/MyContributions.jsx @@ -1,15 +1,24 @@ import { useState, useEffect, useMemo } from "react"; +import { useHistory } from "react-router-dom"; import ExamService from "../../services/ExamService"; -import { CustomRow, CustomCol, CustomTable, CustomPreloader } from "../../components/custom"; +import { + CustomRow, + CustomCol, + CustomTable, + CustomPreloader, + CustomCard, + CustomButton +} from "../../components/custom"; import EnarmUtil from "../../modules/EnarmUtil"; const STATUS_LABELS = { - pending: Pendiente, - published: Publicado, - rejected: Rechazado, + pending: Pendiente, + published: Publicado, + rejected: Rechazado, }; const MyContributions = () => { + const history = useHistory(); const [contributions, setContributions] = useState({ clinical_cases: [], questions: [] }); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -55,8 +64,8 @@ const MyContributions = () => { if (loading) { return ( -
- +
+
); } @@ -70,51 +79,61 @@ const MyContributions = () => {

Mis Contribuciones

- {error && ( + {error ? (
- info_outline + {error}
- )} - - - - - - - Tipo - Nombre / Texto - Especialidad - Status - - - - {allContributions.length === 0 ? ( + ) : allContributions.length === 0 ? ( + + + + +
¿Aún no has contribuido?
+

Tus aportaciones ayudan a miles de médicos a prepararse para el ENARM. ¡Comienza hoy mismo!

+ history.push('/contribuir')} + icon="add_circle_outline" + iconPosition="left" + > + Nueva Contribución + +
+
+
+ ) : ( + + + + - - No has realizado ninguna contribución todavía. - + Tipo + Nombre / Texto + Especialidad + Status - ) : ( - allContributions.map((item, idx) => ( + + + {allContributions.map((item, idx) => ( {item.type} {item.display_name} - + {especialidadesMap.get(item.category_id?.toString()) || 'N/A'} {STATUS_LABELS[item.status] || item.status} - )) - )} - - - - + ))} + +
+
+
+ )}
); }; diff --git a/src/pages/Player/MyContributions.test.jsx b/src/pages/Player/MyContributions.test.jsx index 7adde24..25fd762 100644 --- a/src/pages/Player/MyContributions.test.jsx +++ b/src/pages/Player/MyContributions.test.jsx @@ -40,7 +40,9 @@ describe('MyContributions Component', () => { ); await waitFor(() => { - expect(screen.getByText(/No has realizado ninguna contribución todavía/i)).toBeDefined(); + expect(screen.getByText(/¿Aún no has contribuido\?/i)).toBeDefined(); + expect(screen.getByText(/Tus aportaciones ayudan a miles de médicos/i)).toBeDefined(); + expect(screen.getByText(/Nueva Contribución/i)).toBeDefined(); }); }); diff --git a/src/services/KnowledgeBaseService.js b/src/services/KnowledgeBaseService.js new file mode 100644 index 0000000..f93289d --- /dev/null +++ b/src/services/KnowledgeBaseService.js @@ -0,0 +1,17 @@ +import axios from 'axios'; +import BaseService from './BaseService'; + +export default class KnowledgeBaseService extends BaseService { + static getTopics() { + const headers = this.getHeaders(); + return axios.get(BaseService.getURL('v2/knowledge-base'), headers); + } + + static searchTopics(search) { + const headers = this.getHeaders(); + return axios.get(BaseService.getURL('v2/knowledge-base'), { + ...headers, + params: { search } + }); + } +} diff --git a/src/test/setupTests.js b/src/test/setupTests.js index 59b821c..f08543e 100644 --- a/src/test/setupTests.js +++ b/src/test/setupTests.js @@ -50,11 +50,79 @@ const ensureMaterializeGlobal = () => { ensureMaterializeGlobal(); +const originalConsoleError = console.error; +const originalConsoleWarn = console.warn; +let _capturedConsoleError; +let _capturedConsoleWarn; + +// Patterns that indicate test-quality issues (act warnings, prop leaks, controlled inputs) +const CONSOLE_ERROR_PATTERNS = [ + /not wrapped in act/i, + /changing an uncontrolled/i, + /does not recognize the.*prop/i, + /checked.*onChange/i, + /value.*onChange/i, + /validateDOMNesting/i, + /Received `.+?` for a non-boolean attribute/i, + /Invalid DOM property/i, + /Each child in a list should have a unique.*key/i, + /`value` prop on `%s` should not be null/i, +]; +const CONSOLE_WARN_PATTERNS = [ + /not wrapped in act/i, + /changing an uncontrolled/i, + /does not recognize the.*prop/i, + /Received `.+?` for a non-boolean attribute/i, + /Invalid DOM property/i, + /Each child in a list should have a unique.*key/i, +]; + +function hasBadConsoleCall(calls, patterns) { + return calls.some(args => + args.some(arg => + typeof arg === 'string' && patterns.some(p => p.test(arg)) + ) + ); +} + +function formatCalls(calls) { + return calls.map(c => c.map(a => (typeof a === 'string' ? a : JSON.stringify(a))).join(' ')).join('\n'); +} + beforeEach(() => { + globalThis.__SKIP_CONSOLE_CHECKS__ = false; ensureMaterializeGlobal(); globalThis.M.toast.mockClear(); globalThis.M.updateTextFields.mockClear(); globalThis.M.validate_field.mockClear(); globalThis.M.Modal.init.mockClear(); globalThis.M.FormSelect.init.mockClear(); + _capturedConsoleError = console.error = vi.fn(); + _capturedConsoleWarn = console.warn = vi.fn(); +}); + +afterEach(() => { + const skipChecks = globalThis.__SKIP_CONSOLE_CHECKS__; + globalThis.__SKIP_CONSOLE_CHECKS__ = false; + const errors = _capturedConsoleError.mock.calls; + const warns = _capturedConsoleWarn.mock.calls; + console.error = originalConsoleError; + console.warn = originalConsoleWarn; + + if (!skipChecks && hasBadConsoleCall(errors, CONSOLE_ERROR_PATTERNS)) { + const bad = errors.filter(args => + args.some(arg => typeof arg === 'string' && CONSOLE_ERROR_PATTERNS.some(p => p.test(arg))) + ); + throw new Error( + `Unexpected console.error warning(s) during test:\n${formatCalls(bad)}` + ); + } + if (!skipChecks && hasBadConsoleCall(warns, CONSOLE_WARN_PATTERNS)) { + const bad = warns.filter(args => + args.some(arg => typeof arg === 'string' && CONSOLE_WARN_PATTERNS.some(p => p.test(arg))) + ); + throw new Error( + `Unexpected console.warn warning(s) during test:\n${formatCalls(bad)}` + ); + } }); diff --git a/src/v2/__tests__/V2DirectMessaging.test.jsx b/src/v2/__tests__/V2DirectMessaging.test.jsx index aec5cad..b2af6a7 100644 --- a/src/v2/__tests__/V2DirectMessaging.test.jsx +++ b/src/v2/__tests__/V2DirectMessaging.test.jsx @@ -75,6 +75,7 @@ describe('V2DirectMessaging', () => { }); it('renders loading state initially', () => { + MessageService.getConversations.mockReturnValue(new Promise(() => {})); render( diff --git a/src/v2/__tests__/V2ErrorReview.test.jsx b/src/v2/__tests__/V2ErrorReview.test.jsx index 327fd50..b0d4af6 100644 --- a/src/v2/__tests__/V2ErrorReview.test.jsx +++ b/src/v2/__tests__/V2ErrorReview.test.jsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import V2ErrorReview from '../pages/V2ErrorReview'; import ExamService from '../../services/ExamService'; @@ -59,6 +59,7 @@ describe('V2ErrorReview', () => { }); it('renders loading state initially', () => { + ExamService.getUserAnswers.mockReturnValue(new Promise(() => {})); render( @@ -167,11 +168,12 @@ describe('V2ErrorReview', () => { expect(questionCards.length).toBeGreaterThan(0); if (questionCards.length > 0) { - questionCards[0].click(); + fireEvent.click(questionCards[0]); } // Verify click happened - no assertion needed on visual element expect(true).toBeTruthy(); + await waitFor(() => {}); }); it('uses mock data when API returns empty array', async () => { diff --git a/src/v2/__tests__/V2Examen.test.jsx b/src/v2/__tests__/V2Examen.test.jsx index 3ec7b92..68331b8 100644 --- a/src/v2/__tests__/V2Examen.test.jsx +++ b/src/v2/__tests__/V2Examen.test.jsx @@ -44,6 +44,7 @@ describe('V2Examen', () => { }); it('renders loading state initially', () => { + ExamService.getCaso.mockReturnValue(new Promise(() => {})); render( diff --git a/src/v2/__tests__/V2FlashcardCreator.test.jsx b/src/v2/__tests__/V2FlashcardCreator.test.jsx index 9764b64..c03c556 100644 --- a/src/v2/__tests__/V2FlashcardCreator.test.jsx +++ b/src/v2/__tests__/V2FlashcardCreator.test.jsx @@ -46,6 +46,7 @@ describe('V2FlashcardCreator', () => { }); it('renders loading state initially', () => { + ExamService.loadCategories.mockReturnValue(new Promise(() => {})); render( diff --git a/src/v2/__tests__/V2FlashcardStudy.test.jsx b/src/v2/__tests__/V2FlashcardStudy.test.jsx index 40c3543..1b5cd29 100644 --- a/src/v2/__tests__/V2FlashcardStudy.test.jsx +++ b/src/v2/__tests__/V2FlashcardStudy.test.jsx @@ -41,6 +41,7 @@ describe('V2FlashcardStudy', () => { }); it('renders loading state initially', () => { + FlashcardService.getDueFlashcards.mockReturnValue(new Promise(() => {})); render( @@ -72,6 +73,7 @@ describe('V2FlashcardStudy', () => { // Progress bar should exist const progressBar = document.querySelector('.v2-linear-progress'); expect(progressBar).toBeDefined(); + await waitFor(() => {}); }); it('shows flashcard front text', async () => { diff --git a/src/v2/__tests__/V2PlayerDashboard.test.jsx b/src/v2/__tests__/V2PlayerDashboard.test.jsx index 3305e9e..41e5046 100644 --- a/src/v2/__tests__/V2PlayerDashboard.test.jsx +++ b/src/v2/__tests__/V2PlayerDashboard.test.jsx @@ -58,6 +58,9 @@ describe('V2PlayerDashboard', () => { }); it('renders loading state initially', () => { + UserService.getUserStats.mockReturnValue(new Promise(() => {})); + ExamService.loadCategories.mockReturnValue(new Promise(() => {})); + AchievementService.getAchievements.mockReturnValue(new Promise(() => {})); render( diff --git a/src/v2/components/V2Navi.jsx b/src/v2/components/V2Navi.jsx index f05d04d..99854ce 100644 --- a/src/v2/components/V2Navi.jsx +++ b/src/v2/components/V2Navi.jsx @@ -119,6 +119,7 @@ const V2Navi = () => { variant={isMobile ? 'mobile' : 'desktop'} /> + ); }; diff --git a/src/v2/components/V2Navi.test.jsx b/src/v2/components/V2Navi.test.jsx index 7bd6c54..dbabf5a 100644 --- a/src/v2/components/V2Navi.test.jsx +++ b/src/v2/components/V2Navi.test.jsx @@ -37,7 +37,10 @@ describe('V2Navi', () => { it('toggles theme on button click', () => { const setAttributeSpy = vi.spyOn(document.documentElement, 'setAttribute'); - const getItemSpy = vi.spyOn(Storage.prototype, 'getItem').mockReturnValue('light'); + const getItemSpy = vi.spyOn(Storage.prototype, 'getItem').mockImplementation((key) => { + if (key === 'theme') return 'light'; + return null; + }); const setItemSpy = vi.spyOn(Storage.prototype, 'setItem'); render( diff --git a/src/v2/pages/V2CouponCenter.test.jsx b/src/v2/pages/V2CouponCenter.test.jsx index c08c21a..e21eadd 100644 --- a/src/v2/pages/V2CouponCenter.test.jsx +++ b/src/v2/pages/V2CouponCenter.test.jsx @@ -89,5 +89,6 @@ describe('V2CouponCenter', () => { const backButton = screen.getByLabelText('Volver'); fireEvent.click(backButton); expect(mockGoBack).toHaveBeenCalled(); + await waitFor(() => {}); }); }); diff --git a/src/v2/pages/V2FlashcardStudy.jsx b/src/v2/pages/V2FlashcardStudy.jsx index c591822..dc6134f 100644 --- a/src/v2/pages/V2FlashcardStudy.jsx +++ b/src/v2/pages/V2FlashcardStudy.jsx @@ -236,10 +236,10 @@ const Flashcard = ({ card, isFlipped, onFlip }) => ( // Quality rating buttons (SM-2 mapped to UI) const QualityButtons = ({ onRate, disabled }) => { const buttons = [ - { quality: 1, label: 'Otra vez', sublabel: '< 1 día', color: '#ba1a1a', icon: 'replay', hint: '1' }, - { quality: 3, label: 'Difícil', sublabel: '2-3 días', color: '#9c4247', icon: 'sentiment_dissatisfied', hint: '2' }, - { quality: 4, label: 'Bien', sublabel: '4-6 días', color: '#0fa397', icon: 'sentiment_satisfied', hint: '3' }, - { quality: 5, label: 'Fácil', sublabel: '7+ días', color: '#4a6360', icon: 'sentiment_very_satisfied', hint: '4' } + { quality: 1, label: 'Otra vez', shortcut: '1', sublabel: '< 1 día', color: '#ba1a1a', icon: 'replay' }, + { quality: 3, label: 'Difícil', shortcut: '2', sublabel: '2-3 días', color: '#9c4247', icon: 'sentiment_dissatisfied' }, + { quality: 4, label: 'Bien', shortcut: '3 / Espacio', sublabel: '4-6 días', color: '#0fa397', icon: 'sentiment_satisfied' }, + { quality: 5, label: 'Fácil', shortcut: '4', sublabel: '7+ días', color: '#4a6360', icon: 'sentiment_very_satisfied' } ]; return ( @@ -255,14 +255,14 @@ const QualityButtons = ({ onRate, disabled }) => { opacity: disabled ? 0.5 : 1, border: 'none' }} - aria-label={`Atajo: ${btn.hint}. Calificar como ${btn.label}`} - title={`Atajo: ${btn.hint}`} + aria-label={`Calificar como ${btn.label} (atajo: tecla ${btn.shortcut})`} + title={`Atajo: ${btn.shortcut}`} > - {btn.label} [{btn.hint}] + {btn.label} [{btn.shortcut}] {btn.sublabel} @@ -388,30 +388,6 @@ const V2FlashcardStudy = () => { } }, [currentCard, currentIndex, flashcards.length, isSubmitting]); - // Keyboard shortcuts - useEffect(() => { - const handleKeyDown = (e) => { - if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; - - if (e.key === ' ' || e.key === 'Enter') { - e.preventDefault(); - if (!isFlipped) { - handleFlip(); - } else if (!isSubmitting) { - handleRate(4); // "Bien" is default - } - } else if (isFlipped && !isSubmitting) { - if (e.key === '1') handleRate(1); - if (e.key === '2') handleRate(3); - if (e.key === '3') handleRate(4); - if (e.key === '4') handleRate(5); - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [isFlipped, isSubmitting, handleFlip, handleRate]); - // Restart session const handleRestartSession = useCallback(() => { setCurrentIndex(0); @@ -420,11 +396,42 @@ const V2FlashcardStudy = () => { setSessionStats({ total: 0, good: 0, again: 0 }); fetchDueFlashcards(); }, [fetchDueFlashcards]); - + // Go home const handleGoHome = useCallback(() => { history.push('/dashboard'); }, [history]); + + // Keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e) => { + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; + if (loading || isSubmitting || isSessionComplete || !currentCard) return; + + const { key } = e; + + if (!isFlipped) { + if (key === ' ' || key === 'Enter') { + e.preventDefault(); + handleFlip(); + } + } else { + if (key === '1') { + handleRate(1); + } else if (key === '2') { + handleRate(3); + } else if (key === '3' || key === ' ' || key === 'Enter') { + e.preventDefault(); + handleRate(4); + } else if (key === '4') { + handleRate(5); + } + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [loading, isSubmitting, isSessionComplete, currentCard, isFlipped, handleFlip, handleRate]); // Loading state if (loading) { @@ -526,11 +533,11 @@ const V2FlashcardStudy = () => {
)} diff --git a/src/v2/pages/V2FlashcardStudy.test.jsx b/src/v2/pages/V2FlashcardStudy.test.jsx index cde3828..0d370a2 100644 --- a/src/v2/pages/V2FlashcardStudy.test.jsx +++ b/src/v2/pages/V2FlashcardStudy.test.jsx @@ -144,7 +144,7 @@ describe('V2FlashcardStudy', () => { }); }); - it('flips the card when pressing Space', async () => { + it('handles keyboard shortcuts for flipping and rating', async () => { render( @@ -153,56 +153,27 @@ describe('V2FlashcardStudy', () => { await waitFor(() => screen.getByText('¿Cuál es la tríada de Virchow?')); + // Press Space to flip fireEvent.keyDown(window, { key: ' ' }); - await waitFor(() => { expect(screen.getByText(/Estasis venosa/)).toBeTruthy(); }); - }); - - it('rates the card when pressing numeric keys when flipped', async () => { - render( - - - - ); - await waitFor(() => screen.getByText('¿Cuál es la tríada de Virchow?')); - - // Flip first - fireEvent.keyDown(window, { key: 'Enter' }); - await waitFor(() => screen.getByText(/Estasis venosa/)); - - // Rate with '4' (Fácil) + // Press '4' to rate as Easy (Quality 5) fireEvent.keyDown(window, { key: '4' }); - await waitFor(() => { expect(screen.getByText('Agente causal más común de epiglotitis')).toBeTruthy(); }); - expect(FlashcardService.reviewFlashcard).toHaveBeenCalledWith(1, 5); // 4 maps to quality 5 - }); - - it('rates the card as "Bien" when pressing Space when flipped', async () => { - render( - - - - ); - - await waitFor(() => screen.getByText('¿Cuál es la tríada de Virchow?')); - - // Flip first - fireEvent.keyDown(window, { key: ' ' }); - await waitFor(() => screen.getByText(/Estasis venosa/)); - - // Rate with Space - fireEvent.keyDown(window, { key: ' ' }); - + // Press ' ' to rate as Good (Quality 4) - after flipping + fireEvent.keyDown(window, { key: ' ' }); // Flip second card await waitFor(() => { - expect(screen.getByText('Agente causal más común de epiglotitis')).toBeTruthy(); + expect(screen.getByText(/Haemophilus influenzae/)).toBeTruthy(); }); - expect(FlashcardService.reviewFlashcard).toHaveBeenCalledWith(1, 4); // Space maps to quality 4 + fireEvent.keyDown(window, { key: ' ' }); // Rate as Good + await waitFor(() => { + expect(screen.getByText('Signo de Murphy positivo indica...')).toBeTruthy(); + }); }); }); diff --git a/src/v2/pages/V2ForgotPassword.jsx b/src/v2/pages/V2ForgotPassword.jsx index c1ee7b6..406a654 100644 --- a/src/v2/pages/V2ForgotPassword.jsx +++ b/src/v2/pages/V2ForgotPassword.jsx @@ -30,7 +30,7 @@ const V2ForgotPassword = () => {
- lock_reset +

Recuperar Acceso

@@ -52,6 +52,7 @@ const V2ForgotPassword = () => { value={email} onChange={(e) => setEmail(e.target.value)} required + aria-required='true' className='v2-input-with-prefix' />

@@ -65,7 +66,7 @@ const V2ForgotPassword = () => { ) : ( <> Enviar Instrucciones - send + )} diff --git a/src/v2/pages/V2KnowledgeBase.jsx b/src/v2/pages/V2KnowledgeBase.jsx index 56b6c08..70aafe6 100644 --- a/src/v2/pages/V2KnowledgeBase.jsx +++ b/src/v2/pages/V2KnowledgeBase.jsx @@ -1,53 +1,72 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { alertSuccess } from '../../services/AlertService'; +import KnowledgeBaseService from '../../services/KnowledgeBaseService'; import '../styles/v2-theme.css'; const V2KnowledgeBase = () => { const [search, setSearch] = useState(''); - const [expandedCategory, setExpandedCategory] = useState(null); + const [expandedTopic, setExpandedTopic] = useState(null); + const [topics, setTopics] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); - const [data] = useState({ - categories: [ - { - id: '1', - title: 'Guías de Práctica Clínica', - description: 'Repositorio oficial de GPCs vigentes en México', - topics: [ - { id: 't1', title: 'GPC Hipertensión Arterial Sistémica' }, - { id: 't2', title: 'GPC Diabetes Mellitus Tipo 2' }, - { id: 't3', title: 'GPC Insuficiencia Cardiaca' } - ] - }, - { - id: '2', - title: 'Esquemas de Vacunación', - description: 'Actualización 2024 de cartillas nacionales de salud', - topics: [ - { id: 't4', title: 'Esquema Infantil (0-9 años)' }, - { id: 't5', title: 'Esquema Adolescente (10-19 años)' }, - { id: 't6', title: 'Esquema Adulto Mayor' } - ] - }, - { - id: '3', - title: 'Algoritmos Diagnósticos', - description: 'Diagramas de flujo para toma de decisiones clínicas', - topics: [ - { id: 't7', title: 'Algoritmo Sepsis y Choque Séptico' }, - { id: 't8', title: 'Algoritmo Manejo de Asma Aguda' }, - { id: 't9', title: 'Algoritmo Código Infarto' } - ] - } - ] - }); + useEffect(() => { + loadTopics(); + }, []); - const filteredCategories = data.categories.map(cat => ({ - ...cat, - topics: cat.topics.filter(topic => - topic.title.toLowerCase().includes(search.toLowerCase()) || - cat.title.toLowerCase().includes(search.toLowerCase()) + const loadTopics = async () => { + try { + setLoading(true); + setError(null); + const response = await KnowledgeBaseService.getTopics(); + setTopics(response.data.topics || []); + } catch (err) { + console.error('Error loading knowledge base:', err); + setError('Error al cargar la base de conocimientos'); + setTopics([]); + } finally { + setLoading(false); + } + }; + + const filteredTopics = topics.filter(topic => + topic.title.toLowerCase().includes(search.toLowerCase()) || + topic.articles?.some(article => + article.title.toLowerCase().includes(search.toLowerCase()) ) - })).filter(cat => cat.topics.length > 0); + ); + + const handleArticleClick = (articleTitle) => { + alertSuccess('Próximamente', `${articleTitle} estará disponible pronto.`); + }; + + if (loading) { + return ( +
+
+ sync +
+

Cargando base de conocimientos...

+
+ ); + } + + if (error) { + return ( +
+
+ error_outline +
+

{error}

+ +
+ ); + } return (
@@ -68,58 +87,66 @@ const V2KnowledgeBase = () => { />
- {/* Categories List */} + {/* Topics List */}
- {filteredCategories.map(cat => ( -
+ {filteredTopics.map(topic => ( +
setExpandedCategory(expandedCategory === cat.id ? null : cat.id)} + onClick={() => setExpandedTopic(expandedTopic === topic.id ? null : topic.id)} role='button' tabIndex={0} - aria-expanded={expandedCategory === cat.id} - aria-label={cat.title} - onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setExpandedCategory(expandedCategory === cat.id ? null : cat.id); } }} + aria-expanded={expandedTopic === topic.id} + aria-label={topic.title} + onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setExpandedTopic(expandedTopic === topic.id ? null : topic.id); } }} >
-

{cat.title}

-

{cat.description}

+

{topic.title}

+

+ {topic.articles?.length || 0} artículo{topic.articles?.length !== 1 ? 's' : ''} disponible{topic.articles?.length !== 1 ? 's' : ''} +

-