Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'

export default [
{ ignores: ['dist'] },
{ ignores: ['dist', 'coverage'] },
{
files: ['**/*.{js,jsx}'],
languageOptions: {
Expand Down
13 changes: 13 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@
<link rel="icon" type="image/svg+xml" href="/help.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Computer Selector Helper</title>
<script>
// Apply the saved/system theme before first paint to avoid a flash (FOUC).
// Mirrors the logic in src/hooks/useTheme.js (localStorage key 'csh-theme').
(function () {
try {
var stored = localStorage.getItem('csh-theme');
var theme = stored === 'light' || stored === 'dark'
? stored
: (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
} catch (e) { /* private mode / no storage — fall back to light */ }
})();
</script>
<script type="application/ld+json">
{
"@context": "https://schema.org",
Expand Down
5 changes: 5 additions & 0 deletions public/sitemap.xml
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,9 @@
<changefreq>monthly</changefreq>
<priority>0.9</priority>
</url>
<url>
<loc>https://computerselectorhelper.vercel.app/compare</loc>
<changefreq>monthly</changefreq>
<priority>0.9</priority>
</url>
</urlset>
46 changes: 45 additions & 1 deletion src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,23 @@ body {
width: 100%;
}

.AppHeader__brand h1 {
.AppHeader__brand a.AppHeader__brandLink {
display: inline-block;
margin: 0;
font-size: clamp(1.8rem, 4vw, 2.5rem);
font-weight: 700;
line-height: 1.2;
text-align: center;
color: var(--color-text);
text-decoration: none;
}

.AppHeader__actions {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 0.75rem;
}

.AppNav {
Expand Down Expand Up @@ -156,4 +169,35 @@ h1 {
.App button {
background-color: #f9f9f9;
}
}

/* ---------------------------------------------------------------------------
Dark theme overrides
The light gradient, the translucent-white nav glass and the hard-coded link
colours are remapped for dark mode. Light mode is left untouched.
--------------------------------------------------------------------------- */
:root[data-theme='dark'] #root {
background: var(--color-background);
background: linear-gradient(189deg, #0b1220 0%, #1e293b 100%);
}

:root[data-theme='dark'] .AppNav {
background: rgba(30, 41, 59, 0.6);
border-color: rgba(148, 163, 184, 0.2);
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4);
}

:root[data-theme='dark'] .AppNav__link {
color: var(--color-text-muted);
}

:root[data-theme='dark'] .AppNav__link:hover {
background: rgba(59, 130, 246, 0.18);
color: var(--color-primary-hover);
}

:root[data-theme='dark'] .AppNav__link.active {
background: var(--color-primary);
color: #ffffff;
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.4);
}
20 changes: 13 additions & 7 deletions src/App.jsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,29 @@
import './App.css'
import { BrowserRouter, NavLink } from 'react-router-dom'
import { BrowserRouter, NavLink, Link } from 'react-router-dom'
import { HelmetProvider } from 'react-helmet-async'
import AppRoutes from './routes/AppRoutes'
import { Analytics } from "@vercel/analytics/react"
import ErrorBoundary from './components/ErrorBoundary/ErrorBoundary'
import ThemeToggle from './components/ThemeToggle/ThemeToggle'

function App() {
return (
<HelmetProvider>
<BrowserRouter>
<header className="AppHeader">
<div className="AppHeader__brand">
<h1>Computer Selector <span className='title__alter'>Helper</span></h1>
<Link to="/" className="AppHeader__brandLink" aria-label="Computer Selector Helper — Inicio">
Computer Selector <span className='title__alter'>Helper</span>
</Link>
</div>
<div className="AppHeader__actions">
<nav className="AppNav" aria-label="Navegación principal">
<NavLink to="/" end className={({ isActive }) => isActive ? 'AppNav__link active' : 'AppNav__link'}>Inicio</NavLink>
<NavLink to="/quiz" className={({ isActive }) => isActive ? 'AppNav__link active' : 'AppNav__link'}>Quiz</NavLink>
<NavLink to="/compare" className={({ isActive }) => isActive ? 'AppNav__link active' : 'AppNav__link'}>Comparar</NavLink>
</nav>
<ThemeToggle />
</div>
<nav className="AppNav" aria-label="Navegación principal">
<NavLink to="/" end className={({ isActive }) => isActive ? 'AppNav__link active' : 'AppNav__link'}>Inicio</NavLink>
<NavLink to="/quiz" className={({ isActive }) => isActive ? 'AppNav__link active' : 'AppNav__link'}>Quiz</NavLink>
<NavLink to="/compare" className={({ isActive }) => isActive ? 'AppNav__link active' : 'AppNav__link'}>Comparar</NavLink>
</nav>
</header>
<main>
<ErrorBoundary>
Expand Down
59 changes: 59 additions & 0 deletions src/__tests__/Landing.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { HelmetProvider } from 'react-helmet-async'
import Landing from '../pages/Landing/Landing'
import { addEntry, getHistory } from '../utils/historyStore'

beforeEach(() => {
localStorage.clear()
})

const renderLanding = () => render(
<HelmetProvider>
<MemoryRouter>
<Landing />
</MemoryRouter>
</HelmetProvider>
)

describe('Landing — recent history (F-02)', () => {
it('does not render the history section when there is no history', () => {
renderLanding()
expect(screen.queryByText(/tus consultas recientes/i)).not.toBeInTheDocument()
})

it('renders history cards from localStorage', () => {
addEntry({
encodedAnswers: 'abc',
summary: { processor: 'Intel i7', ram: '16 GB', gpu: 'RTX 4050', budget: 'high' },
})
renderLanding()
expect(screen.getByText(/tus consultas recientes/i)).toBeInTheDocument()
expect(screen.getByText(/intel i7/i)).toBeInTheDocument()
expect(screen.getByText(/16 GB/)).toBeInTheDocument()
expect(screen.getByText(/RTX 4050/)).toBeInTheDocument()
expect(screen.getByText(/\$12,000/)).toBeInTheDocument()
})

it('links each history card back to the quiz with its encoded plan', () => {
addEntry({
encodedAnswers: 'plan123',
summary: { processor: 'Intel i5', ram: '8 GB', gpu: 'Iris Xe', budget: 'low' },
})
renderLanding()
const link = screen.getByRole('link', { name: /intel i5/i })
expect(link).toHaveAttribute('href', '/quiz?plan=plan123')
})

it('clears history when the clear button is clicked', () => {
addEntry({
encodedAnswers: 'abc',
summary: { processor: 'Intel i7', ram: '16 GB', gpu: 'RTX 4050', budget: 'high' },
})
renderLanding()
fireEvent.click(screen.getByRole('button', { name: /limpiar/i }))
expect(screen.queryByText(/tus consultas recientes/i)).not.toBeInTheDocument()
expect(getHistory()).toHaveLength(0)
})
})
127 changes: 64 additions & 63 deletions src/__tests__/Recommendation.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,17 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import Recommendation from '../components/Recommendation/Recommendation'
import questions from '../constants/questions'

const mockNavigate = vi.fn()
vi.mock('react-router-dom', async (importOriginal) => {
const actual = await importOriginal()
return { ...actual, useNavigate: () => mockNavigate }
})

beforeEach(() => {
localStorage.clear()
mockNavigate.mockClear()
})

const renderWithRouter = () => render(
Expand All @@ -13,6 +21,25 @@ const renderWithRouter = () => render(
</MemoryRouter>
)

// Walks the full quiz, picking the first option for every question.
// For the technical-level question (INF-01) it picks "yes" or "no" on demand.
const completeQuiz = ({ technical = true } = {}) => {
for (let i = 0; i < questions.length; i++) {
const q = questions[i]
const checkboxes = screen.queryAllByRole('checkbox')
const radios = screen.queryAllByRole('radio')
if (q.id === 'isTechnical') {
fireEvent.click(radios[technical ? 0 : 1])
} else if (checkboxes.length > 0) {
fireEvent.click(checkboxes[0])
} else if (radios.length > 0) {
fireEvent.click(radios[0])
}
const nextButton = screen.getByRole('button', { name: /siguiente|ver resultados/i })
fireEvent.click(nextButton)
}
}

vi.mock('../utils/convertToSpecs', () => ({
default: vi.fn((answers) => ({
processor: 'Intel i5 / Ryzen 5',
Expand All @@ -24,8 +51,9 @@ vi.mock('../utils/convertToSpecs', () => ({
touchscreen: false,
portability: 'Media o baja',
battery: 'Media (4-7h)',
warnings: [],
warnings: ['⚠️ Advertencia crítica de prueba', 'Nota informativa de prueba'],
rationale: ['Test rationale'],
flags: { isTechnical: answers.isTechnical === 'yes' },
laptopClass: [
{
id: 'test-1',
Expand All @@ -49,6 +77,9 @@ vi.mock('../utils/convertToSpecs', () => ({
image: 'https://example.com/2.jpg',
link: 'https://example.com/2',
},
{ id: 'test-3', name: 'Test Laptop 3', use: 'X', specs: 'i5', gpu: 'Iris Xe', price: '$11,000 MXN', portability: 'Media', image: '', link: '' },
{ id: 'test-4', name: 'Test Laptop 4', use: 'X', specs: 'i5', gpu: 'Iris Xe', price: '$12,000 MXN', portability: 'Media', image: '', link: '' },
{ id: 'generic-1', name: 'Clase genérica', isGeneric: true, use: 'X', specs: 'i5', gpu: 'Iris Xe', price: 'Consultar precio', portability: 'Media', image: '', link: '' },
],
})),
}))
Expand Down Expand Up @@ -92,18 +123,7 @@ describe('Recommendation — Quiz flow', () => {

it('completes quiz and shows results', () => {
renderWithRouter()
// Answer all questions with first option
for (let i = 0; i < 10; i++) {
const checkboxes = screen.queryAllByRole('checkbox')
const radios = screen.queryAllByRole('radio')
if (checkboxes.length > 0) {
fireEvent.click(checkboxes[0])
} else if (radios.length > 0) {
fireEvent.click(radios[0])
}
const nextButton = screen.getByRole('button', { name: /siguiente|ver resultados/i })
fireEvent.click(nextButton)
}
completeQuiz()
// Should show results — component renders "Especificaciones Recomendadas" heading
expect(screen.getByText(/especificaciones recomendadas/i)).toBeInTheDocument()
})
Expand All @@ -112,37 +132,14 @@ describe('Recommendation — Quiz flow', () => {
describe('Recommendation — Results page', () => {
it('shows laptop cards after quiz completion', () => {
renderWithRouter()
// Answer all questions
for (let i = 0; i < 10; i++) {
const checkboxes = screen.queryAllByRole('checkbox')
const radios = screen.queryAllByRole('radio')
if (checkboxes.length > 0) {
fireEvent.click(checkboxes[0])
} else if (radios.length > 0) {
fireEvent.click(radios[0])
}
const nextButton = screen.getByRole('button', { name: /siguiente|ver resultados/i })
fireEvent.click(nextButton)
}
// Should show laptop cards
completeQuiz()
expect(screen.getByText('Test Laptop 1')).toBeInTheDocument()
expect(screen.getByText('Test Laptop 2')).toBeInTheDocument()
})

it('shows spec cards on results page', () => {
renderWithRouter()
// Answer all questions
for (let i = 0; i < 10; i++) {
const checkboxes = screen.queryAllByRole('checkbox')
const radios = screen.queryAllByRole('radio')
if (checkboxes.length > 0) {
fireEvent.click(checkboxes[0])
} else if (radios.length > 0) {
fireEvent.click(radios[0])
}
const nextButton = screen.getByRole('button', { name: /siguiente|ver resultados/i })
fireEvent.click(nextButton)
}
completeQuiz()
expect(screen.getByText('Procesador')).toBeInTheDocument()
expect(screen.getByText('RAM')).toBeInTheDocument()
expect(screen.getByText('Almacenamiento')).toBeInTheDocument()
Expand All @@ -151,40 +148,44 @@ describe('Recommendation — Results page', () => {

it('shows reset button on results page', () => {
renderWithRouter()
// Answer all questions
for (let i = 0; i < 10; i++) {
const checkboxes = screen.queryAllByRole('checkbox')
const radios = screen.queryAllByRole('radio')
if (checkboxes.length > 0) {
fireEvent.click(checkboxes[0])
} else if (radios.length > 0) {
fireEvent.click(radios[0])
}
const nextButton = screen.getByRole('button', { name: /siguiente|ver resultados/i })
fireEvent.click(nextButton)
}
completeQuiz()
expect(screen.getByRole('button', { name: /reiniciar/i })).toBeInTheDocument()
})

it('resets quiz when reset button is clicked', () => {
renderWithRouter()
// Answer all questions
for (let i = 0; i < 10; i++) {
const checkboxes = screen.queryAllByRole('checkbox')
const radios = screen.queryAllByRole('radio')
if (checkboxes.length > 0) {
fireEvent.click(checkboxes[0])
} else if (radios.length > 0) {
fireEvent.click(radios[0])
}
const nextButton = screen.getByRole('button', { name: /siguiente|ver resultados/i })
fireEvent.click(nextButton)
}
// Click reset
completeQuiz()
const resetButton = screen.getByRole('button', { name: /reiniciar/i })
fireEvent.click(resetButton)
// Should be back to first question
expect(screen.getByRole('heading', { level: 2 })).toBeInTheDocument()
expect(screen.queryByText(/especificaciones recomendadas/i)).not.toBeInTheDocument()
})
})

describe('Recommendation — Technical level (INF-01)', () => {
it('technical user sees the rationale expanded and all warnings', () => {
renderWithRouter()
completeQuiz({ technical: true })
expect(screen.getByText('Test rationale')).toBeInTheDocument()
expect(screen.getByText(/advertencia crítica de prueba/i)).toBeInTheDocument()
expect(screen.getByText(/nota informativa de prueba/i)).toBeInTheDocument()
})

it('non-technical user sees a collapsed rationale and only critical warnings', () => {
renderWithRouter()
completeQuiz({ technical: false })
expect(screen.queryByText('Test rationale')).not.toBeInTheDocument()
expect(screen.getByText(/advertencia crítica de prueba/i)).toBeInTheDocument()
expect(screen.queryByText(/nota informativa de prueba/i)).not.toBeInTheDocument()
})
})

describe('Recommendation — compare deep-link', () => {
it('caps the compare deep-link at 3 non-generic models and excludes generics', () => {
renderWithRouter()
completeQuiz()
fireEvent.click(screen.getByRole('button', { name: /comparar/i }))
expect(mockNavigate).toHaveBeenCalledWith('/compare?models=test-1,test-2,test-3')
})
})
Loading
Loading