feat(pasarela): datos inferidos juntos en una pantalla "Datos del local"
deploy-branch / deploy (push) Successful in 2m18s
deploy-branch / teardown (push) Skipped
tests / Unitarias + componente + build (pull_request) Successful in 2m23s
tests / Integración (Nominatim real, opt-in) (pull_request) Skipped
tests / Unitarias + componente + build (push) Successful in 2m30s
tests / Integración (Nominatim real, opt-in) (push) Skipped

- Nuevo PasoDatosLocal con nombre, provincia y dirección (ahora editable)
  en la misma pantalla, con aviso cuando vienen de la inferencia
- El wizard pasa de 6 a 5 pasos: Ubicación → Datos del local → Categoría
  → Tipo específico → Resumen
- Eliminados PasoNombre y PasoProvincia (absorbidos)
- Spec principal actualizada al nuevo orden de pantallas
This commit is contained in:
2026-09-12 19:00:33 +02:00
parent 30b7cc884e
commit 49e965b248
7 changed files with 117 additions and 95 deletions
@@ -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 - **THEN** la pasarela se cierra y se descarta el borrador en curso
### Requirement: Orden de las pantallas empezando por Ubicación ### 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 #### Scenario: Primera pantalla
- **WHEN** se abre la pasarela - **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 - **WHEN** el usuario está en la pantalla de Tipo específico
- **THEN** puede continuar sin seleccionar ninguna subcategoría - **THEN** puede continuar sin seleccionar ninguna subcategoría
### Requirement: Inferencia de Nombre y Provincia desde la Ubicación ### Requirement: Inferencia de Nombre, Provincia y Dirección 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. 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 - **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 #### Scenario: Valores corregibles
- **WHEN** los valores inferidos no son correctos - **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 #### Scenario: Degradación sin inferencia
- **WHEN** la inferencia falla o devuelve campos vacíos - **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 ### 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. 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.
+2 -4
View File
@@ -1,8 +1,7 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import useEsMovil from "../hooks/useEsMovil.js"; import useEsMovil from "../hooks/useEsMovil.js";
import PasoUbicacion from "./PasoUbicacion.jsx"; import PasoUbicacion from "./PasoUbicacion.jsx";
import PasoNombre from "./PasoNombre.jsx"; import PasoDatosLocal from "./PasoDatosLocal.jsx";
import PasoProvincia from "./PasoProvincia.jsx";
import PasoCategoria from "./PasoCategoria.jsx"; import PasoCategoria from "./PasoCategoria.jsx";
import PasoSubcategoria from "./PasoSubcategoria.jsx"; import PasoSubcategoria from "./PasoSubcategoria.jsx";
import PasoResumen from "./PasoResumen.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. // gestiona su propio botón de envío en PasoResumen.
const PASOS = [ const PASOS = [
{ id: "ubicacion", titulo: "Ubicación", Componente: PasoUbicacion, puedeContinuar: (f) => f.lat != null && f.lng != null }, { 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: "datos", titulo: "Datos del local", Componente: PasoDatosLocal, puedeContinuar: (f) => (f.nombre || "").trim().length >= 2 && !!f.provincia },
{ id: "provincia", titulo: "Provincia", Componente: PasoProvincia, puedeContinuar: (f) => !!f.provincia },
{ id: "categoria", titulo: "Categoría", Componente: PasoCategoria, puedeContinuar: (f) => !!f.categoria }, { id: "categoria", titulo: "Categoría", Componente: PasoCategoria, puedeContinuar: (f) => !!f.categoria },
{ id: "subcategoria", titulo: "Tipo específico", Componente: PasoSubcategoria, puedeContinuar: () => true }, { id: "subcategoria", titulo: "Tipo específico", Componente: PasoSubcategoria, puedeContinuar: () => true },
{ id: "resumen", titulo: "Revisión y envío", Componente: PasoResumen, puedeContinuar: () => true }, { id: "resumen", titulo: "Revisión y envío", Componente: PasoResumen, puedeContinuar: () => true },
+73
View File
@@ -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 (
<div style={{ display: "flex", flexDirection: "column", gap: 12 }}>
{algunoInferido && (
<div role="status" style={{
display: "flex", alignItems: "flex-start", gap: 6,
background: "#EAFAF1", color: "#1E8449",
padding: "6px 10px", borderRadius: "var(--border-radius-md)", fontSize: 12,
}}>
<i className="ti ti-sparkles" aria-hidden="true" style={{ fontSize: 14, flexShrink: 0, marginTop: 1 }}></i>
<span style={{ lineHeight: 1.4 }}>
Rellenados automáticamente desde la ubicación: compruébalos y corrige lo que haga falta.
</span>
</div>
)}
<div>
<label htmlFor="paso-nombre" style={etiqueta}>
Nombre del local <span style={{ color: "#8B0000" }}>*</span>
</label>
<input
id="paso-nombre"
style={inputStyle}
autoComplete="off"
placeholder="Bar El Olivo, Clínica San José..."
value={form.nombre}
onChange={(e) => setForm((f) => ({ ...f, nombre: e.target.value }))}
/>
</div>
<div>
<label htmlFor="paso-provincia" style={etiqueta}>
Provincia <span style={{ color: "#8B0000" }}>*</span>
</label>
<select
id="paso-provincia"
style={inputStyle}
value={form.provincia}
onChange={(e) => setForm((f) => ({ ...f, provincia: e.target.value }))}
>
<option value="">Selecciona provincia...</option>
{PROVINCIAS.map((p) => <option key={p} value={p}>{p}</option>)}
</select>
</div>
<div>
<label htmlFor="paso-direccion" style={etiqueta}>Dirección</label>
<input
id="paso-direccion"
style={inputStyle}
autoComplete="off"
placeholder="Calle, número, ciudad"
value={form.direccion}
onChange={(e) => setForm((f) => ({ ...f, direccion: e.target.value }))}
aria-describedby="paso-direccion-ayuda"
/>
<p id="paso-direccion-ayuda" style={{ fontSize: 11, color: "var(--color-text-tertiary)", margin: "4px 0 0" }}>
Opcional: se rellena automáticamente desde la ubicación; corrígela si hace falta.
</p>
</div>
</div>
);
}
-26
View File
@@ -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 (
<div>
<label htmlFor="paso-nombre" style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>
Nombre del local <span style={{ color: "#8B0000" }}>*</span>
</label>
<input
id="paso-nombre"
style={inputStyle}
autoComplete="off"
placeholder="Bar El Olivo, Clínica San José..."
value={form.nombre}
onChange={(e) => setForm((f) => ({ ...f, nombre: e.target.value }))}
/>
<p style={{ fontSize: 11, color: "var(--color-text-tertiary)", margin: "6px 0 0" }}>
{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."}
</p>
</div>
);
}
-26
View File
@@ -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 (
<div>
<label htmlFor="paso-provincia" style={{ fontSize: 12, color: "var(--color-text-secondary)", display: "block", marginBottom: 4 }}>
Provincia <span style={{ color: "#8B0000" }}>*</span>
</label>
<select
id="paso-provincia"
style={inputStyle}
value={form.provincia}
onChange={(e) => setForm((f) => ({ ...f, provincia: e.target.value }))}
>
<option value="">Selecciona provincia...</option>
{PROVINCIAS.map((p) => <option key={p} value={p}>{p}</option>)}
</select>
<p style={{ fontSize: 11, color: "var(--color-text-tertiary)", margin: "6px 0 0" }}>
Si se rellenó automáticamente desde la ubicación, verifica que es correcta.
</p>
</div>
);
}
+10 -8
View File
@@ -20,6 +20,7 @@ const FORM_RESUELTO = {
proveedorUbicacion: "coordenadas", proveedorUbicacion: "coordenadas",
lat: 41.3851, lng: 2.1734, lat: 41.3851, lng: 2.1734,
nombre: "Bar X", provincia: "Barcelona", nombre: "Bar X", provincia: "Barcelona",
direccion: "Calle Mayor 5, Alcalá de Henares",
categoria: "Restauración", subcategoria: "Tapas y raciones", categoria: "Restauración", subcategoria: "Tapas y raciones",
}; };
@@ -41,14 +42,13 @@ const propsBase = {
const siguiente = () => screen.getByRole("button", { name: "Siguiente" }); const siguiente = () => screen.getByRole("button", { name: "Siguiente" });
describe("ModalPasarela", () => { 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(<ModalPasarela {...propsBase} form={FORM_VACIO} />); render(<ModalPasarela {...propsBase} form={FORM_VACIO} />);
expect(screen.getByLabelText(/ubicación del local/i)).toBeTruthy(); expect(screen.getByLabelText(/ubicación del local/i)).toBeTruthy();
expect(screen.getByText(/paso 1 de 6/i)).toBeTruthy(); expect(screen.getByText(/paso 1 de 5/i)).toBeTruthy();
expect(screen.getAllByRole("generic", { hidden: true })).toBeTruthy(); // indicador de progreso: 5 barras
// indicador de progreso: 6 barras
const barras = document.querySelectorAll('[aria-hidden="true"] span[style*="width: 16px"]'); 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", () => { it("Siguiente deshabilitado sin resolución de ubicación válida", () => {
@@ -56,12 +56,14 @@ describe("ModalPasarela", () => {
expect(siguiente().disabled).toBe(true); 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(<ModalPasarela {...propsBase} form={FORM_RESUELTO} />); render(<ModalPasarela {...propsBase} form={FORM_RESUELTO} />);
expect(siguiente().disabled).toBe(false); expect(siguiente().disabled).toBe(false);
fireEvent.click(siguiente()); 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(/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 })); fireEvent.click(screen.getByRole("button", { name: /atrás/i }));
// vuelve a ubicación con la entrada conservada // 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", () => { it("en el paso final muestra el resumen con su propio botón de envío", () => {
render(<ModalPasarela {...propsBase} form={FORM_RESUELTO} />); render(<ModalPasarela {...propsBase} form={FORM_RESUELTO} />);
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.getByRole("button", { name: /enviar propuesta/i })).toBeTruthy();
expect(screen.queryByRole("button", { name: "Siguiente" })).toBeNull(); expect(screen.queryByRole("button", { name: "Siguiente" })).toBeNull();
}); });
+26 -25
View File
@@ -2,8 +2,7 @@
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import { useState } from "react"; import { useState } from "react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import PasoNombre from "../../src/components/PasoNombre.jsx"; import PasoDatosLocal from "../../src/components/PasoDatosLocal.jsx";
import PasoProvincia from "../../src/components/PasoProvincia.jsx";
import PasoCategoria from "../../src/components/PasoCategoria.jsx"; import PasoCategoria from "../../src/components/PasoCategoria.jsx";
import PasoSubcategoria from "../../src/components/PasoSubcategoria.jsx"; import PasoSubcategoria from "../../src/components/PasoSubcategoria.jsx";
import { PROVINCIAS } from "../../src/constants/provincias.js"; import { PROVINCIAS } from "../../src/constants/provincias.js";
@@ -12,8 +11,7 @@ import { CATEGORIAS, NOMBRES_CATEGORIAS } from "../../src/constants/categorias.j
afterEach(cleanup); afterEach(cleanup);
// Harness con useState real: los pasos escriben a través de setForm y el // 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, // 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).
function renderPaso(Componente, inicial) { function renderPaso(Componente, inicial) {
const estado = { form: inicial }; const estado = { form: inicial };
function Wrapper() { function Wrapper() {
@@ -25,35 +23,38 @@ function renderPaso(Componente, inicial) {
return estado; return estado;
} }
describe("PasoNombre", () => { describe("PasoDatosLocal (datos inferidos en una pantalla)", () => {
it("muestra el nombre inferido y es editable", () => { it("muestra los tres campos pre-rellenados con lo inferido, en la misma pantalla", () => {
const estado = renderPaso(PasoNombre, { nombre: "Bar El Olivo" }); renderPaso(PasoDatosLocal, { nombre: "Puerta del Sol", provincia: "Madrid", direccion: "Puerta del Sol, 28013 Madrid" });
const input = screen.getByLabelText(/nombre del local/i); expect(screen.getByLabelText(/nombre del local/i).value).toBe("Puerta del Sol");
expect(input.value).toBe("Bar El Olivo"); expect(screen.getByLabelText(/provincia/i).value).toBe("Madrid");
fireEvent.change(input, { target: { value: "Bar La Plaza" } }); 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"); 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); const select = screen.getByLabelText(/provincia/i);
expect(select.value).toBe("La Coruña");
const opciones = [...select.options].map((o) => o.value); const opciones = [...select.options].map((o) => o.value);
expect(opciones).toEqual(["", ...PROVINCIAS]); expect(opciones).toEqual(["", ...PROVINCIAS]);
fireEvent.change(select, { target: { value: "Madrid" } }); fireEvent.change(select, { target: { value: "Gerona" } });
// (la aserción de estado se cubre con el harness en el resto de pasos) 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)", () => { it("vacíos y sin aviso de inferencia cuando la inferencia falló", () => {
renderPaso(PasoProvincia, { provincia: "" }); renderPaso(PasoDatosLocal, { nombre: "", provincia: "", direccion: "" });
expect(screen.getByLabelText(/nombre del local/i).value).toBe("");
expect(screen.getByLabelText(/provincia/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();
}); });
}); });