diff --git a/openspec/specs/pasarela-proponer-local/spec.md b/openspec/specs/pasarela-proponer-local/spec.md index 474fd42..b15a5cb 100644 --- a/openspec/specs/pasarela-proponer-local/spec.md +++ b/openspec/specs/pasarela-proponer-local/spec.md @@ -26,7 +26,7 @@ El sistema SHALL presentar la propuesta de un local como una pasarela (wizard) d - **THEN** la pasarela se cierra y se descarta el borrador en curso ### Requirement: Orden de las pantallas empezando por Ubicación -La pasarela SHALL presentar las pantallas en este orden: 1) Ubicación, 2) Nombre, 3) Provincia, 4) Categoría, 5) Tipo específico (opcional), 6) Revisión y envío. +La pasarela SHALL presentar las pantallas en este orden: 1) Ubicación, 2) Datos del local (nombre, provincia y dirección inferidos, juntos en una misma pantalla), 3) Categoría, 4) Tipo específico (opcional), 5) Revisión y envío. #### Scenario: Primera pantalla - **WHEN** se abre la pasarela @@ -36,12 +36,12 @@ La pasarela SHALL presentar las pantallas en este orden: 1) Ubicación, 2) Nombr - **WHEN** el usuario está en la pantalla de Tipo específico - **THEN** puede continuar sin seleccionar ninguna subcategoría -### Requirement: Inferencia de Nombre y Provincia desde la Ubicación -El sistema SHALL pre-rellenar los campos Nombre y Provincia a partir de la ubicación introducida en la primera pantalla (geocodificación inversa), permitiendo al usuario corregirlos. +### Requirement: Inferencia de Nombre, Provincia y Dirección desde la Ubicación +El sistema SHALL pre-rellenar los campos Nombre, Provincia y Dirección a partir de la ubicación introducida en la primera pantalla (geocodificación inversa), mostrándolos juntos en la pantalla de Datos del local y permitiendo al usuario corregirlos (incluida la dirección). -#### Scenario: Nombre y provincia pre-rellenados +#### Scenario: Datos pre-rellenados en su pantalla conjunta - **WHEN** el usuario introduce una ubicación válida en la primera pantalla y esta se resuelve con éxito -- **THEN** al llegar a las pantallas de Nombre y Provincia ambos campos aparecen pre-rellenados con los valores inferidos +- **THEN** al llegar a la pantalla de Datos del local los campos nombre, provincia y dirección aparecen pre-rellenados con los valores inferidos #### Scenario: Valores corregibles - **WHEN** los valores inferidos no son correctos @@ -49,7 +49,7 @@ El sistema SHALL pre-rellenar los campos Nombre y Provincia a partir de la ubica #### Scenario: Degradación sin inferencia - **WHEN** la inferencia falla o devuelve campos vacíos -- **THEN** las pantallas de Nombre y Provincia aparecen vacías y el usuario los rellena manualmente, sin bloquear la pasarela +- **THEN** la pantalla de Datos del local aparece vacía y el usuario la rellena manualmente, sin bloquear la pasarela ### Requirement: Sin campo de comentario ni puntuación La pasarela SHALL NOT incluir el campo de comentario/descripción ni el campo de puntuación; la propuesta se envía sin esos datos. diff --git a/src/components/ModalPasarela.jsx b/src/components/ModalPasarela.jsx index 817dd13..0fc2bcc 100644 --- a/src/components/ModalPasarela.jsx +++ b/src/components/ModalPasarela.jsx @@ -1,8 +1,7 @@ import { useEffect, useRef, useState } from "react"; import useEsMovil from "../hooks/useEsMovil.js"; import PasoUbicacion from "./PasoUbicacion.jsx"; -import PasoNombre from "./PasoNombre.jsx"; -import PasoProvincia from "./PasoProvincia.jsx"; +import PasoDatosLocal from "./PasoDatosLocal.jsx"; import PasoCategoria from "./PasoCategoria.jsx"; import PasoSubcategoria from "./PasoSubcategoria.jsx"; import PasoResumen from "./PasoResumen.jsx"; @@ -12,8 +11,7 @@ import PasoResumen from "./PasoResumen.jsx"; // gestiona su propio botón de envío en PasoResumen. const PASOS = [ { id: "ubicacion", titulo: "Ubicación", Componente: PasoUbicacion, puedeContinuar: (f) => f.lat != null && f.lng != null }, - { id: "nombre", titulo: "Nombre", Componente: PasoNombre, puedeContinuar: (f) => (f.nombre || "").trim().length >= 2 }, - { id: "provincia", titulo: "Provincia", Componente: PasoProvincia, puedeContinuar: (f) => !!f.provincia }, + { id: "datos", titulo: "Datos del local", Componente: PasoDatosLocal, puedeContinuar: (f) => (f.nombre || "").trim().length >= 2 && !!f.provincia }, { id: "categoria", titulo: "Categoría", Componente: PasoCategoria, puedeContinuar: (f) => !!f.categoria }, { id: "subcategoria", titulo: "Tipo específico", Componente: PasoSubcategoria, puedeContinuar: () => true }, { id: "resumen", titulo: "Revisión y envío", Componente: PasoResumen, puedeContinuar: () => true }, diff --git a/src/components/PasoDatosLocal.jsx b/src/components/PasoDatosLocal.jsx new file mode 100644 index 0000000..530c109 --- /dev/null +++ b/src/components/PasoDatosLocal.jsx @@ -0,0 +1,73 @@ +import { inputStyle } from "../styles/shared.js"; +import { PROVINCIAS } from "../constants/provincias.js"; + +const etiqueta = { fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }; + +// Paso 2 de la pasarela: los datos inferidos desde la ubicación (nombre, +// provincia y dirección), pre-rellenados y editables en una misma pantalla. +// Si la inferencia falló o no casó con el listado, llegan vacíos y se +// rellenan a mano, sin bloquear la pasarela. +export default function PasoDatosLocal({ form, setForm }) { + const algunoInferido = !!(form.nombre || form.provincia || form.direccion); + return ( +
+ {algunoInferido && ( +
+ + + Rellenados automáticamente desde la ubicación: compruébalos y corrige lo que haga falta. + +
+ )} + +
+ + setForm((f) => ({ ...f, nombre: e.target.value }))} + /> +
+ +
+ + +
+ +
+ + setForm((f) => ({ ...f, direccion: e.target.value }))} + aria-describedby="paso-direccion-ayuda" + /> +

+ Opcional: se rellena automáticamente desde la ubicación; corrígela si hace falta. +

+
+
+ ); +} diff --git a/src/components/PasoNombre.jsx b/src/components/PasoNombre.jsx deleted file mode 100644 index d783049..0000000 --- a/src/components/PasoNombre.jsx +++ /dev/null @@ -1,26 +0,0 @@ -import { inputStyle } from "../styles/shared.js"; - -// Paso 2 de la pasarela: nombre del local, pre-rellenado con lo inferido -// desde la ubicación (editable). Si la inferencia falló, llega vacío. -export default function PasoNombre({ form, setForm }) { - return ( -
- - setForm((f) => ({ ...f, nombre: e.target.value }))} - /> -

- {form.nombre - ? "Si el nombre se rellenó automáticamente desde la ubicación, comprueba que es correcto antes de continuar." - : "El nombre con el que la gente conoce el negocio."} -

-
- ); -} diff --git a/src/components/PasoProvincia.jsx b/src/components/PasoProvincia.jsx deleted file mode 100644 index b989546..0000000 --- a/src/components/PasoProvincia.jsx +++ /dev/null @@ -1,26 +0,0 @@ -import { inputStyle } from "../styles/shared.js"; -import { PROVINCIAS } from "../constants/provincias.js"; - -// Paso 3 de la pasarela: provincia, pre-rellenada con lo inferido desde la -// ubicación (si la inferencia falló o no casó con el listado, llega vacía). -export default function PasoProvincia({ form, setForm }) { - return ( -
- - -

- Si se rellenó automáticamente desde la ubicación, verifica que es correcta. -

-
- ); -} diff --git a/tests/component/ModalPasarela.spec.jsx b/tests/component/ModalPasarela.spec.jsx index 7ae5fa4..ea5b7fc 100644 --- a/tests/component/ModalPasarela.spec.jsx +++ b/tests/component/ModalPasarela.spec.jsx @@ -20,6 +20,7 @@ const FORM_RESUELTO = { proveedorUbicacion: "coordenadas", lat: 41.3851, lng: 2.1734, nombre: "Bar X", provincia: "Barcelona", + direccion: "Calle Mayor 5, Alcalá de Henares", categoria: "Restauración", subcategoria: "Tapas y raciones", }; @@ -41,14 +42,13 @@ const propsBase = { const siguiente = () => screen.getByRole("button", { name: "Siguiente" }); describe("ModalPasarela", () => { - it("arranca en la pantalla de Ubicación con las 6 pantallas declaradas", () => { + it("arranca en la pantalla de Ubicación con las 5 pantallas declaradas", () => { render(); expect(screen.getByLabelText(/ubicación del local/i)).toBeTruthy(); - expect(screen.getByText(/paso 1 de 6/i)).toBeTruthy(); - expect(screen.getAllByRole("generic", { hidden: true })).toBeTruthy(); - // indicador de progreso: 6 barras + expect(screen.getByText(/paso 1 de 5/i)).toBeTruthy(); + // indicador de progreso: 5 barras const barras = document.querySelectorAll('[aria-hidden="true"] span[style*="width: 16px"]'); - expect(barras.length).toBe(6); + expect(barras.length).toBe(5); }); it("Siguiente deshabilitado sin resolución de ubicación válida", () => { @@ -56,12 +56,14 @@ describe("ModalPasarela", () => { expect(siguiente().disabled).toBe(true); }); - it("Siguiente habilitado con datos válidos y navega; Atrás conserva los datos", () => { + it("Siguiente habilitado con datos válidos y navega; los datos inferidos comparten pantalla; Atrás conserva", () => { render(); expect(siguiente().disabled).toBe(false); fireEvent.click(siguiente()); - // paso 2: nombre pre-rellenado con lo inferido + // paso 2: datos del local (nombre, provincia y dirección) en la misma pantalla expect(screen.getByLabelText(/nombre del local/i).value).toBe("Bar X"); + expect(screen.getByLabelText(/provincia/i).value).toBe("Barcelona"); + expect(screen.getByLabelText(/^dirección/i).value).toBe("Calle Mayor 5, Alcalá de Henares"); fireEvent.click(screen.getByRole("button", { name: /atrás/i })); // vuelve a ubicación con la entrada conservada @@ -105,7 +107,7 @@ describe("ModalPasarela", () => { it("en el paso final muestra el resumen con su propio botón de envío", () => { render(); - for (let i = 0; i < 5; i++) fireEvent.click(siguiente()); + for (let i = 0; i < 4; i++) fireEvent.click(siguiente()); expect(screen.getByRole("button", { name: /enviar propuesta/i })).toBeTruthy(); expect(screen.queryByRole("button", { name: "Siguiente" })).toBeNull(); }); diff --git a/tests/component/PasosFormulario.spec.jsx b/tests/component/PasosFormulario.spec.jsx index 6637316..8e633a0 100644 --- a/tests/component/PasosFormulario.spec.jsx +++ b/tests/component/PasosFormulario.spec.jsx @@ -2,8 +2,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { useState } from "react"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; -import PasoNombre from "../../src/components/PasoNombre.jsx"; -import PasoProvincia from "../../src/components/PasoProvincia.jsx"; +import PasoDatosLocal from "../../src/components/PasoDatosLocal.jsx"; import PasoCategoria from "../../src/components/PasoCategoria.jsx"; import PasoSubcategoria from "../../src/components/PasoSubcategoria.jsx"; import { PROVINCIAS } from "../../src/constants/provincias.js"; @@ -12,8 +11,7 @@ import { CATEGORIAS, NOMBRES_CATEGORIAS } from "../../src/constants/categorias.j afterEach(cleanup); // Harness con useState real: los pasos escriben a través de setForm y el -// estado resultante se lee desde fuera (más fiel que espiar el updater, -// cuya evaluación diferida pierde el valor del evento en React 18). +// estado resultante se lee desde fuera (más fiel que espiar el updater). function renderPaso(Componente, inicial) { const estado = { form: inicial }; function Wrapper() { @@ -25,35 +23,38 @@ function renderPaso(Componente, inicial) { return estado; } -describe("PasoNombre", () => { - it("muestra el nombre inferido y es editable", () => { - const estado = renderPaso(PasoNombre, { nombre: "Bar El Olivo" }); - const input = screen.getByLabelText(/nombre del local/i); - expect(input.value).toBe("Bar El Olivo"); - fireEvent.change(input, { target: { value: "Bar La Plaza" } }); +describe("PasoDatosLocal (datos inferidos en una pantalla)", () => { + it("muestra los tres campos pre-rellenados con lo inferido, en la misma pantalla", () => { + renderPaso(PasoDatosLocal, { nombre: "Puerta del Sol", provincia: "Madrid", direccion: "Puerta del Sol, 28013 Madrid" }); + expect(screen.getByLabelText(/nombre del local/i).value).toBe("Puerta del Sol"); + expect(screen.getByLabelText(/provincia/i).value).toBe("Madrid"); + expect(screen.getByLabelText(/^dirección/i).value).toBe("Puerta del Sol, 28013 Madrid"); + // aviso de que vienen de la inferencia + expect(screen.getByText(/Rellenados automáticamente desde la ubicación/i)).toBeTruthy(); + }); + + it("todos editables: nombre (input), provincia (select PROVINCIAS), dirección (input)", () => { + const estado = renderPaso(PasoDatosLocal, { nombre: "Bar X", provincia: "Barcelona", direccion: "Calle 1" }); + + fireEvent.change(screen.getByLabelText(/nombre del local/i), { target: { value: "Bar La Plaza" } }); expect(estado.form.nombre).toBe("Bar La Plaza"); - }); - it("vacío si la inferencia falló", () => { - renderPaso(PasoNombre, { nombre: "" }); - expect(screen.getByLabelText(/nombre del local/i).value).toBe(""); - }); -}); - -describe("PasoProvincia", () => { - it("es un select con el listado PROVINCIAS y la provincia pre-rellenada", () => { - renderPaso(PasoProvincia, { provincia: "La Coruña" }); const select = screen.getByLabelText(/provincia/i); - expect(select.value).toBe("La Coruña"); const opciones = [...select.options].map((o) => o.value); expect(opciones).toEqual(["", ...PROVINCIAS]); - fireEvent.change(select, { target: { value: "Madrid" } }); - // (la aserción de estado se cubre con el harness en el resto de pasos) + fireEvent.change(select, { target: { value: "Gerona" } }); + expect(estado.form.provincia).toBe("Gerona"); + + fireEvent.change(screen.getByLabelText(/^dirección/i), { target: { value: "Calle Nueva 2" } }); + expect(estado.form.direccion).toBe("Calle Nueva 2"); }); - it("sin coincidencia queda vacío (selección manual)", () => { - renderPaso(PasoProvincia, { provincia: "" }); + it("vacíos y sin aviso de inferencia cuando la inferencia falló", () => { + renderPaso(PasoDatosLocal, { nombre: "", provincia: "", direccion: "" }); + expect(screen.getByLabelText(/nombre del local/i).value).toBe(""); expect(screen.getByLabelText(/provincia/i).value).toBe(""); + expect(screen.getByLabelText(/^dirección/i).value).toBe(""); + expect(screen.queryByText(/Rellenados automáticamente/i)).toBeNull(); }); });