From fe42b4b6cea66fab79f00ea4850d4fc9d0ebdbd7 Mon Sep 17 00:00:00 2001 From: dev Date: Fri, 11 Sep 2026 02:44:44 +0200 Subject: [PATCH] Allow users to update song ratings --- src/lib/components/Valoracion.svelte | 40 ++++++++--- src/lib/components/Valoracion.svelte.spec.ts | 68 +++++++++++++++++++ .../(sitio)/canciones/[slug]/+page.svelte | 6 +- 3 files changed, 104 insertions(+), 10 deletions(-) create mode 100644 src/lib/components/Valoracion.svelte.spec.ts diff --git a/src/lib/components/Valoracion.svelte b/src/lib/components/Valoracion.svelte index 3fb80a9..9f63910 100644 --- a/src/lib/components/Valoracion.svelte +++ b/src/lib/components/Valoracion.svelte @@ -16,13 +16,14 @@ import { page } from '$app/state'; interface Props { + cancionSlug: string; /** Media de 1 a 5, o `null` si todavía no ha votado nadie. */ media: number | null; /** Lo que puso quien mira, si entró y votó. */ mia?: number; } - let { media, mia }: Props = $props(); + let { cancionSlug, media, mia }: Props = $props(); const ESTRELLAS = [1, 2, 3, 4, 5]; @@ -31,7 +32,18 @@ /** Se envía mientras vuela la respuesta, para que la elección no parpadee. */ let enviando = $state(undefined); - const elegida = $derived(enviando ?? mia); + /** + * Último voto que la acción confirmó en esta visita. + * + * No se depende solo de que el `load` vuelva a traer `mia`: entre la + * respuesta de la acción y esa recarga el radio regresaba al valor anterior, + * y daba la impresión —además de dejar el control en un estado incoherente— + * de que un voto ya emitido no se podía modificar. + */ + let confirmada = $state<{ cancionSlug: string; puntuacion: number } | undefined>(undefined); + const elegida = $derived( + enviando ?? (confirmada?.cancionSlug === cancionSlug ? confirmada.puntuacion : undefined) ?? mia + );
@@ -40,14 +52,19 @@ method="POST" action="?/valorar" use:enhance={({ formData }) => { - enviando = Number(formData.get('puntuacion')); - return async ({ update }) => { - await update({ reset: false }); - enviando = undefined; + const puntuacion = Number(formData.get('puntuacion')); + enviando = puntuacion; + return async ({ result, update }) => { + try { + await update({ reset: false }); + if (result.type === 'success') confirmada = { cancionSlug, puntuacion }; + } finally { + enviando = undefined; + } }; }} > -
+
Tu valoración, de 1 a 5
{#each ESTRELLAS as estrella (estrella)} @@ -125,6 +142,8 @@ } .estrellas label { + position: relative; + display: inline-flex; cursor: pointer; font-size: 1.25rem; line-height: 1; @@ -164,9 +183,12 @@ /* El radio real sigue ahí: es quien recibe el foco y el teclado. */ .estrellas input { position: absolute; - width: 1px; - height: 1px; + inset: 0; + width: 100%; + height: 100%; + margin: 0; opacity: 0; + cursor: pointer; } .estrellas label:has(input:focus-visible) { diff --git a/src/lib/components/Valoracion.svelte.spec.ts b/src/lib/components/Valoracion.svelte.spec.ts new file mode 100644 index 0000000..f461484 --- /dev/null +++ b/src/lib/components/Valoracion.svelte.spec.ts @@ -0,0 +1,68 @@ +import { page } from 'vitest/browser'; +import { describe, expect, it, vi } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import Valoracion from './Valoracion.svelte'; + +vi.mock('$app/state', () => ({ + page: { + url: new URL('http://localhost/canciones/prometiste'), + data: { usuario: { id: 'usuario-prueba' } }, + form: null + } +})); + +type AlEnviar = (entrada: { + formData: FormData; +}) => (salida: { + result: { type: 'success'; status: number; data: Record }; + update: (opciones: { reset: boolean }) => Promise; +}) => Promise; + +/* + * El componente solo necesita aquí el contrato de mejora progresiva: capturar + * el formulario, ejecutar su callback y confirmar una respuesta correcta. La + * persistencia se comprueba en el módulo del servidor; esta prueba protege el + * estado que ve y puede volver a pulsar la persona. + */ +vi.mock('$app/forms', () => ({ + enhance(formulario: HTMLFormElement, alEnviar: AlEnviar) { + const enviar = async (evento: SubmitEvent) => { + evento.preventDefault(); + const formData = new FormData(formulario); + const completar = alEnviar({ formData }); + await completar({ + result: { + type: 'success', + status: 200, + data: { valoracion: { puntuacion: Number(formData.get('puntuacion')) } } + }, + update: async () => {} + }); + }; + + formulario.addEventListener('submit', enviar); + return { destroy: () => formulario.removeEventListener('submit', enviar) }; + } +})); + +describe('Valoracion', () => { + it('permite reemplazar una valoración ya emitida', async () => { + const vista = render(Valoracion, { cancionSlug: 'prometiste', media: 2, mia: 2 }); + + const dos = page.getByRole('radio', { name: '2 de 5' }); + const cuatro = page.getByRole('radio', { name: '4 de 5' }); + await expect.element(dos).toBeChecked(); + + await cuatro.click(); + + await expect.element(cuatro).toBeChecked(); + await expect.element(dos).not.toBeChecked(); + await expect.element(cuatro).toBeEnabled(); + + // La confirmación local pertenece a ese tema, no al componente reutilizado + // al navegar a otra ficha de canción. + await vista.rerender({ cancionSlug: 'otra-cancion', media: 1, mia: 1 }); + await expect.element(page.getByRole('radio', { name: '1 de 5' })).toBeChecked(); + await expect.element(cuatro).not.toBeChecked(); + }); +}); diff --git a/src/routes/(sitio)/canciones/[slug]/+page.svelte b/src/routes/(sitio)/canciones/[slug]/+page.svelte index 744425f..04fc1d1 100644 --- a/src/routes/(sitio)/canciones/[slug]/+page.svelte +++ b/src/routes/(sitio)/canciones/[slug]/+page.svelte @@ -159,7 +159,11 @@ {/if}
- +