From bc9e0ba112f2e0f3bc8f6d0073035eaa5c060d29 Mon Sep 17 00:00:00 2001
From: jmoredev
Date: Tue, 13 Oct 2026 18:17:28 +0200
Subject: [PATCH 1/6] =?UTF-8?q?feat:=20arrastre=20t=C3=A1ctil,=20resumen?=
=?UTF-8?q?=20del=20examen,=20aviso=20de=20carga=20y=20arreglos=20de=20la?=
=?UTF-8?q?=20fase=201?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Un solo commit porque los cambios están entrelazados en los mismos ficheros
(hoja de estilos, App, store, arnés y documentación).
- Arrastre: `pointercancel` en las dos superficies desde las que se empieza a
arrastrar y `touch-action` efectivo en el lienzo (el inline que escribe
OrbitControls al desmontarse ganaba a la clase, así que la regla era inerte).
- Examen: resumen final con la pieza, el hueco y qué iba ahí, más lo que quedó sin
colocar con su fase; y la barra de estado deja de dar el nombre de la pieza y
del hueco compatible.
- Aviso de carga con porcentaje, no bloqueante, mientras llega un modelo pesado.
- Fase 1: mesa de 6 columnas x 3 filas (filas a 2,1 u) para que dos piezas de la
misma columna visual no se dibujen una detrás de otra, y ventanita con la pieza
seleccionada ampliada (PNG nitido del conector o vista 3D de cerca, sin el
nombre en examen).
- Practica: cartel al empezar cada fase con lo que hay que hacer en ella.
- Arnes: secciones 13-17, check de "misma columna visual" en la 11, preflight de
servidor y de instancia del store, y ONLY= para acotar pasadas.
210 comprobaciones en verde y falsacion de cada regla nueva.
---
AGENTS.md | 44 +-
README.md | 28 +-
src/App.tsx | 11 +
src/data/components.ts | 27 +-
src/data/stages.ts | 11 +-
src/index.css | 143 +++++
src/store/useGameStore.ts | 56 +-
src/three/Scene.tsx | 7 +
src/ui/ComponentZoom.tsx | 49 ++
src/ui/ConnectorBar.tsx | 6 +
src/ui/FinishModal.tsx | 60 +-
src/ui/LoadingBar.tsx | 34 ++
src/ui/StageBanner.tsx | 32 +-
src/ui/StatusBar.tsx | 15 +-
tests/run.mjs | 1087 ++++++++++++++++++++++++++++++++++++-
15 files changed, 1559 insertions(+), 51 deletions(-)
create mode 100644 src/ui/ComponentZoom.tsx
create mode 100644 src/ui/LoadingBar.tsx
diff --git a/AGENTS.md b/AGENTS.md
index af8f3c4..cedbbd6 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -89,7 +89,7 @@ PC-Simulator/
│ │ ├── CalibrationPicker.tsx # captura de puntos en modo calibración
│ │ ├── DebugGrid.tsx # rejilla de coordenadas (?debug=1)
│ │ └── primitives.tsx # helpers Box / Cyl / Ball
-│ ├── ui/ # menú, lista por fases, ficha, progreso, resultados, calibración y leyenda
+│ ├── ui/ # menú, lista por fases, ficha, progreso, resultados, calibración, aviso de carga y leyenda
│ ├── types.ts # tipos compartidos (Stage, MountPoint, ComponentDef…)
│ ├── App.tsx # lienzo 3D + capas de interfaz
│ ├── main.tsx # punto de entrada
@@ -186,6 +186,13 @@ Reglas de layout:
| 49 | **Publicado en GitHub Pages como sitio de proyecto** (`deploy-pages.yml`) | La web pública quedará en `https://jmoredev.github.io/PC-Simulator/` (subruta de sitio de proyecto) una vez activado Pages en el repositorio: el `base` de Vite sale de `BASE_PATH`, que el workflow exporta como `/PC-Simulator/` (sin esa variable todo queda en `/` para desarrollo y preview). Los pull requests hacia `main` solo compilan; publicar lo hace únicamente un push a `main` (el job de despliegue exige además `github.ref == refs/heads/main`, así que una ejecución manual sobre otra rama tampoco publica). Los modelos de placa se publican **tal cual** precisamente para no mover la calibración (`mounts`, `rear`, `ports` se midieron sobre esos `.glb` exactos). |
| 50 | **Los paneles laterales se pliegan** y la partida arranca con la lista recogida y la ficha abierta | La lista de fases ocupaba 300 px de lienzo para una información que solo hace falta al elegir pieza, y la ficha se consulta a cada rato: de ahí el reparto por defecto. El estado **no se recuerda** entre partidas, así que cada partida arranca en el mismo punto (y el arnés puede afirmarlo). Plegado, el panel queda como una franja fina con su botón (`◂`/`▸`) y su contenido no se monta siquiera: nada invisible que atrape el foco. No cambia el tamaño del lienzo, porque los paneles son capas flotantes sobre el canvas. |
| 51 | **El examen no da ninguna ayuda** | En examen no se montan los paneles laterales: la lista de fases es el nombre de cada pieza y la ficha es la explicación, así que los dos son la respuesta (decisión 14). Y el imán del arrastre se limita a práctica: de todos los huecos, aquel al que se pega la pieza al acercarla es el correcto, así que el fantasma sigue al puntero (`ghostSnaps` en `src/three/dragGhost.ts`) y solo se pone verde cuando el imán está activo. El hueco candidato se calcula igual, así que soltar coloca y falla exactamente como antes. La leyenda de controles y el aviso de la barra de estado se quedan: no son la respuesta. Consecuencia asumida: en examen se juega arrastrando las piezas de la mesa, porque el «clic y clic» necesita la lista. |
+| 52 | **El arrastre también termina cuando el navegador cancela el gesto** | Las dos superficies desde las que se empieza a arrastrar (el lienzo 3D en `Scene.tsx` y la barra de cables en `ConnectorBar.tsx`) escuchaban `pointermove`/`pointerup` en `window` pero no `pointercancel`, así que un gesto que el navegador se queda —desplazamiento, zoom o gesto del sistema en una pantalla táctil— dejaba la pieza colgada del cursor y solo se salía con `Esc`. Además, el lienzo y la barra llevan `touch-action: none`, que es lo que impide que el navegador se quede el gesto de entrada. La cancelación **cancela**, no suelta: no coloca la pieza y no cuenta intento ni fallo, igual que `Esc`. Consecuencia: en Chromebooks táctiles (o convertibles) el arrastre es el mismo gesto que con ratón. |
+| 53 | **El resumen del examen dice qué se falló y qué quedó sin colocar** | Una nota sobre 100 no enseña nada: `FinishModal` solo mostraba contadores. Ahora el store anota cada fallo con la pieza arrastrada y el hueco señalado (`misses`) y las piezas que quedaron sin poner al rendirse (`unplaced`). El recuento y la lista de lo que falta salen de la **misma** función (`stageComponents()` en `src/data/components.ts`, de la que también se deriva `STAGE_STEPS`), así que no pueden discrepar, y el fallo se anota bajo la **misma** condición que lo cuenta, así que `errors` es siempre el número de filas. Cada fila dice la pieza, el hueco y cuál iba ahí, que es el momento didáctico; las piezas sin colocar llevan además **la fase en la que faltaban**, porque la misma pieza puede faltar dos veces en una partida (identificarla y montarla): sin eso la lista salía repetida y sin explicación, y con la fase la clave de cada fila es única. El resumen es solo del examen (en práctica no hay fallos que anotar) y no cambia la puntuación. |
+| 54 | **Los modelos que tardan se avisan con una barra, sin bloquear** | El `.glb` de la placa pesa hasta 18,8 MB (medido: `motherboard-01` son 652.238 vértices y sus texturas, 0,08 MB, así que el peso es geometría ya cuantizada, no texturas) y con placa aleatoria por carga una de cada tres partidas lo descarga al empezar la fase 2. Antes lo único que se veía era el placeholder procedural, que aparecía y luego se sustituía sin explicar nada. La barra usa `useProgress` de drei sobre el gestor de carga de three, va arriba bajo la barra superior y con `pointer-events: none`, así que no bloquea el juego: se puede seguir mirando la escena. Desaparece al terminar porque `FileLoader` llama a `itemEnd()` en su `.finally()`, también cuando el modelo falla. |
+| 55 | **La mesa de la identificación se reparte en 6 columnas × 3 filas** (filas a 2,1 u) | En la fase 1 están las 18 piezas a la vez y la cámara mira en oblicuo, así que dos piezas con la misma X se dibujan **una detrás de otra**: con la rejilla anterior (4 filas a 1,25) la separación en profundidad dentro de una «misma columna visual» era 0,9 u (~71 px) y en 200 barajados había **1.628 pares** por debajo del suelo. Con 3 filas a 2,1 u son 1,42 u (~110 px) y 0 pares. El ancho se queda en ±4 y la fila de delante en 4,4 **a propósito, medido con la proyección de la cámara**: 9 columnas hasta ±5,2 sacaban piezas de la mesa visible (x≈118 px) y las metían detrás de la ficha (x≈1190-1282), y una fila en 5,8 las cortaba en el borde inferior. El arnés lo fija con el check de «misma columna visual» (§11), que hoy falla si se vuelve a las 4 filas. |
+| 56 | **En examen la barra de estado no da el nombre de la pieza ni el del hueco** | El examen consiste en reconocer las piezas: «CPU seleccionada» o «Suelta aquí: Zócalo de la CPU» era la respuesta (decisión 51). En práctica se sigue diciendo todo; en examen quedan solo las instrucciones («Pieza seleccionada. Arrástrala hasta su sitio.» y «Lleva la pieza hasta el hueco correcto.»). Cubierto en §12. |
+| 57 | **En práctica, cada fase empieza con su cartel** | Antes el cartel salía solo al completar una fase, así que quien arrancaba la partida o saltaba de fase no veía qué había que hacer. Ahora sale al **empezar** cada fase en práctica (también la primera, y también al reiniciar), se quita solo a los 3,6 s, y el estado guarda `lastAdvance` para poder decir «Fase completada» solo cuando se llegó completando y no cuando se saltó. En examen no se monta: el examen no da explicaciones. Cubierto en §16. |
+| 58 | **La pieza seleccionada se ve ampliada en una ventanita** (fase 1) | En la fase 1 están todas las piezas sobre la mesa y los conectores se ven muy pequeños. Al seleccionar una, una ventanita la enseña entera: el **PNG del conector** cuando existe (más nítido que el dibujo 3D: `ps2`, `usb`, `lan`, `hdmi`, `displayport`, `dvi`, `vga`) y, si no, una **vista 3D de cerca** con la misma `ComponentVisual` (drei no trae `View` en esta versión, así que es un mini lienzo). Va al centro-abajo —la captura enseñó que a la izquierda tapaba la lista de fases—, con `pointer-events: none` para no estorbar el arrastre, solo en la fase 1 y **sin el nombre en examen** (misma regla que la 56). Cubierto en §17. |
## 7. Pipeline de assets 3D
@@ -314,7 +321,8 @@ npm run models:optimize -- --force # reoptimiza todo
## 8. Interacción (resumen técnico)
- `TrayItem.onPointerDown` (o el asa de la lista de piezas en la fase 2) inicia
- el arrastre; `DragController` escucha `pointermove`/`pointerup` en `window` y
+ el arrastre; `DragController` escucha `pointermove` y `pointerup` en `window`,
+ y también `pointercancel` (si el navegador se queda el gesto, cancela) y
proyecta el rayo sobre el plano de la fase (`horizontal` o `vertical`).
- `updateDrag` calcula el hueco candidato más cercano dentro de `snapRadius`,
comparando las dos coordenadas del plano de la fase, y resalta el fantasma con
@@ -346,7 +354,37 @@ No hay suite de tests automáticos. Antes de dar por bueno un cambio:
modo calibración y una partida completa de las 3 fases).
El arnés de `tests/` usa Chromium headless vía `puppeteer-core` y es la forma
rápida de detectar regresiones en la colocación de piezas, el avance de fase y
-el conteo de fallos.
+el conteo de fallos. Busca el navegador en las cachés de puppeteer y de
+Playwright y en las instalaciones habituales de Chrome (`CHROME=` lo fija).
+
+**Reinicia el servidor de desarrollo antes de correr el arnés si has tocado
+`src/`.** Con HMR, el import dinámico de `/src/store/useGameStore.ts` que usan
+algunas secciones (§7, §12 y §13) puede devolver una copia **distinta** de la que
+usa la app: Vite añade `?t=…` al módulo invalidado, así que las acciones del
+store dejan de llegar a la partida. El síntoma es desconcertante —la fase no
+avanza, la partida no reacciona— y parece un fallo del código cuando es de la
+prueba. §14 y §15 van por la interfaz con `clickButton` precisamente para no
+depender de qué instancia del módulo tenga la app.
+
+**Y levántalo con el puerto fijado:** `npm run dev -- --port 5199 --strictPort`.
+Sin `--strictPort`, si 5199 sigue ocupado por un servidor anterior —pararlo desde
+una herramienta del agente no siempre mata el proceso de Vite— el servidor se pasa
+al 5200 **en silencio** y el arnés, que habla con 5199, acaba hablando con un
+proceso moribundo: la app carga, las primeras comprobaciones pasan y el resto
+falla con errores de red que no tienen nada que ver con el código. Comprueba la
+línea «Local:» del log. El arnés avisa de esto con un preflight
+(`requireServer()`): si no hay simulador vivo en `BASE_URL`, sale con un mensaje
+claro en vez de con veinte fallos.
+
+**Y que el import del store sea el de la app**: el arnés lleva otro preflight
+(`requireSameStoreInstance()`) que mueve el store desde el import dinámico y
+comprueba que la app lo refleja en el DOM. Sin él, la trampa de HMR de arriba se
+disfrazaba de fallo de producto: siete comprobaciones «se quedó en identificación»
+(job-23) sin decir en ninguna por qué.
+
+**Para comprobar una sola cosa** (por ejemplo falsar un cambio sin pagar la
+pasada entera, que dura unos 11 minutos): `ONLY=touchDrag,loadingBar node
+tests/run.mjs`, con los nombres de `SECTIONS` separados por comas.
## 11. Despliegue (GitHub Pages)
diff --git a/README.md b/README.md
index 9f9da50..4e1d4ec 100644
--- a/README.md
+++ b/README.md
@@ -33,13 +33,23 @@ La versión online para el aula se publica desde la rama `main` en
4. **Periféricos**: monitor, teclado, ratón y altavoces a **escala realista**
junto al PC ya montado.
- **Modo práctica**: al coger una pieza se ilumina su hueco en verde con el
- nombre del sitio.
+ nombre del sitio. Al empezar cada fase aparece un **cartel** con lo que hay que
+ hacer en ella.
+- **Piezas ampliadas**: en la fase 1, la pieza que seleccionas se ve ampliada en
+ una ventanita (los conectores, con su imagen nítida), en práctica y en examen.
- **Modo examen**: sin pistas, cuenta fallos y da una nota sobre 100, con tiempo
- y precisión.
+ y precisión. Al terminar, un **resumen** dice qué se falló —con la pieza que
+ pusiste, el hueco y cuál iba ahí— y qué quedó sin colocar, indicando en qué
+ fase faltaba cada pieza (la misma puede faltar en dos: identificarla y
+ montarla). Sin pistas de ningún tipo: la barra de estado no dice el nombre de
+ la pieza ni el del hueco.
- **Ficha didáctica** de cada componente (qué es, para qué sirve y un dato
curioso), en lenguaje divulgativo.
- **Funciona sin assets**: si no hay modelos `.glb`, se usan formas 3D
procedurales. Solo hay que soltar los modelos y activarlos.
+- **Aviso de carga**: mientras llega un modelo (la placa pesa hasta 18,8 MB) se
+ ve una barra con el porcentaje y qué se está cargando. No bloquea: la escena y
+ la interfaz siguen respondiendo.
- Al completar una fase, la cámara se desplaza sola a la siguiente y aparece un
aviso con lo que toca hacer.
@@ -69,6 +79,9 @@ npm run models:optimize # comprime los .glb de public/assets/models
en la zona iluminada (solo en modo práctica).
- **Ratón**: botón izquierdo para girar la cámara, rueda para acercar/alejar.
- **Esc**: cancela el arrastre en curso.
+- **Pantalla táctil** (Chromebooks y portátiles convertibles): el mismo gesto
+ sirve con el dedo. Si el sistema se queda con el gesto (desplazamiento o
+ zoom), el arrastre se cancela y la pieza vuelve a su sitio: no cuenta fallo.
- **Ayuda**: la tarjeta de controles se abre y se cierra con el botón `?`.
## Desarrollo y ramas
@@ -96,7 +109,9 @@ node tests/run.mjs # calibración + partida completa de las 3 fases
```
Usa Chromium headless (`puppeteer-core`) instalado aparte en `tests/`, así que
-no añade dependencias a la app.
+no añade dependencias a la app. Busca el navegador en las cachés de puppeteer y
+de Playwright y en las instalaciones habituales de Chrome; si no lo encuentra,
+indica la ruta con `CHROME=`.
## Modelos 3D (opcional)
@@ -219,7 +234,7 @@ src/
│ ├── Placeholder.tsx # geometría procedural de cada componente
│ ├── MountZone.tsx # zonas resaltadas de los huecos
│ └── primitives.tsx # cajas, cilindros y esferas reutilizables
-├── ui/ # menú, lista por fases, ficha, progreso, resultados y leyenda
+├── ui/ # menú, lista por fases, ficha, progreso, resultados, aviso de carga y leyenda
└── App.tsx # composición del lienzo y la interfaz
```
@@ -242,5 +257,10 @@ así que se citan como adaptados.
- Pensado para funcionar en portátiles y Chromebooks modestos: si va lento,
baja el `dpr` en `src/App.tsx` o desactiva las sombras (`shadows`).
+- **Peso de los modelos**: el `.glb` más pesado es la placa
+ (`motherboard-01.glb`, 18,8 MB y 652.238 vértices; los otros dos, 9,3 MB y
+ 4,9 MB). Como la placa es aleatoria en cada carga (salvo `?board=`), una de
+ cada tres partidas descarga la grande al empezar la fase 2: en ese momento
+ aparece la barra de progreso.
- El modo examen es una buena evaluación inicial; el modo práctica, para
introducir el vocabulario técnico.
diff --git a/src/App.tsx b/src/App.tsx
index a060e60..c22f420 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -6,10 +6,12 @@ import type { GameMode } from './types'
import { Scene } from './three/Scene'
import { CalibrationPanel } from './ui/CalibrationPanel'
import { ComponentList } from './ui/ComponentList'
+import { ComponentZoom } from './ui/ComponentZoom'
import { ConnectorBar } from './ui/ConnectorBar'
import { ControlsHelp } from './ui/ControlsHelp'
import { FinishModal } from './ui/FinishModal'
import { InfoPanel } from './ui/InfoPanel'
+import { LoadingBar } from './ui/LoadingBar'
import { ModeMenu } from './ui/ModeMenu'
import { StageBanner } from './ui/StageBanner'
import { StatusBar } from './ui/StatusBar'
@@ -85,6 +87,10 @@ export default function App() {
+ {/* El aviso de carga va también en calibración: la placa es el modelo más
+ pesado y allí se carga igual. */}
+
+
{CALIBRATE ? (
) : (
@@ -110,6 +116,11 @@ export default function App() {
{cableBar && }
+ {/* En la fase 1 las piezas se ven pequeñas: la seleccionada aparece
+ ampliada (con su nombre solo en práctica). */}
+ {stage.id === 'identify' && selectedId && (
+
+ )}
{phase === 'building' && }
>
)}
diff --git a/src/data/components.ts b/src/data/components.ts
index 696e8b4..ca4f685 100644
--- a/src/data/components.ts
+++ b/src/data/components.ts
@@ -753,12 +753,31 @@ export const MOUNTS_BY_STAGE: Record = {
peripherals: MOUNTS.filter((m) => m.stage === 'peripherals'),
}
+/**
+ * Piezas que cuentan para terminar una fase: en la identificación, todas
+ * (incluidos los cables que sobran); en el resto, las que tienen sitio.
+ * `STAGE_STEPS` y el resumen final salen de aquí para que el número y la lista
+ * no puedan discrepar.
+ */
+export function stageComponents(stage: StageId): ComponentDef[] {
+ const inStage = COMPONENTS_BY_STAGE[stage]
+ return stage === 'identify' ? inStage : inStage.filter((c) => !c.decoy)
+}
+
/** Piezas que hay que colocar en cada fase (en la identificación, todas). */
export const STAGE_STEPS: Record = {
- identify: COMPONENTS_BY_STAGE.identify.length,
- board: COMPONENTS_BY_STAGE.board.filter((c) => !c.decoy).length,
- ports: COMPONENTS_BY_STAGE.ports.filter((c) => !c.decoy).length,
- peripherals: COMPONENTS_BY_STAGE.peripherals.filter((c) => !c.decoy).length,
+ identify: stageComponents('identify').length,
+ board: stageComponents('board').length,
+ ports: stageComponents('ports').length,
+ peripherals: stageComponents('peripherals').length,
+}
+
+/**
+ * Pieza del catálogo que va en un hueco (la primera que encaja). La usa el
+ * resumen del examen para poder decir qué iba en el sitio del fallo.
+ */
+export function componentForMount(mount: MountPoint): ComponentDef | undefined {
+ return stageComponents(mount.stage).find((c) => mount.accepts.includes(c.kind))
}
/** Colocaciones de toda la partida. */
diff --git a/src/data/stages.ts b/src/data/stages.ts
index 21eeb50..d6edf29 100644
--- a/src/data/stages.ts
+++ b/src/data/stages.ts
@@ -38,9 +38,18 @@ export const STAGES: Stage[] = [
// Rejilla de la mesa: dos piezas contiguas nunca pueden quedar más juntas de
// lo que ocupan (0,75). El desplazamiento aleatorio se deriva de estas
// separaciones (`trayJitter`), así que ensanchar la rejilla las separa.
+ //
+ // 6 columnas × 3 filas = 18 celdas para 18 piezas, con las filas a 2,1 u: dos
+ // piezas de la misma columna —que la cámara oblicua dibuja una detrás de
+ // otra— quedan a 1,4 u en profundidad (~110 px en pantalla) en vez de a 0,9.
+ // Eran justo los conectores pequeños con latiguillo (los tres jacks y el
+ // USB-C) los que parecían estar en el mismo sitio. Las columnas se quedan en
+ // ±4 y la fila de delante en 4,4 a propósito: más ancho o más profundo saca
+ // las piezas de la mesa visible, las mete debajo de los paneles laterales o
+ // las corta en el borde inferior (medido con la proyección de la cámara).
tray: {
cols: [-4, -2.4, -0.8, 0.8, 2.4, 4],
- rows: [0.6, 1.85, 3.1, 4.35],
+ rows: [0.2, 2.3, 4.4],
surfaceY: 0.11,
},
camera: { position: [0, 8.6, 9.2], target: [0, 0, 0.7] },
diff --git a/src/index.css b/src/index.css
index 537758a..8426358 100644
--- a/src/index.css
+++ b/src/index.css
@@ -50,6 +50,26 @@ button {
z-index: 0;
}
+/* Arrastrar una pieza es un gesto del juego, no del navegador: las dos
+ superficies desde las que se empieza a arrastrar (el lienzo 3D y la barra de
+ cables) se quedan el gesto, para que el desplazamiento o el zoom de la página
+ no lo cancelen a mitad.
+
+ En el lienzo hace falta `!important`: `OrbitControls` (three-stdlib) escribe
+ `touch-action: auto` EN LÍNEA sobre el canvas al desmontarse
+ (`controls/OrbitControls.cjs`), y el estilo en línea gana a una clase, así que
+ la regla quedaba inerte y el navegador se quedaba el gesto. Se pone además en
+ el contenedor, porque el navegador decide el gesto con la intersección de la
+ propiedad en el elemento y en sus antepasados. */
+.canvas-host,
+.connbar__item {
+ touch-action: none;
+}
+
+.canvas-host canvas {
+ touch-action: none !important;
+}
+
/* ---------- barra superior ---------- */
.topbar {
position: absolute;
@@ -685,6 +705,92 @@ button {
bottom: 96px;
}
+/* ---------- aviso de carga de los modelos ---------- */
+.loadingbar {
+ position: absolute;
+ top: 74px;
+ left: 50%;
+ transform: translateX(-50%);
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 8px 14px;
+ border-radius: 12px;
+ border: 1px solid var(--border);
+ background: var(--panel);
+ backdrop-filter: blur(12px);
+ font-size: 13px;
+ color: var(--muted);
+ /* Avisa sin estorbar: no se queda con los clics del juego. */
+ pointer-events: none;
+ z-index: 10;
+}
+
+.loadingbar__label {
+ color: var(--text);
+}
+
+.loadingbar__pct {
+ min-width: 38px;
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+}
+
+.loadingbar__track {
+ width: 120px;
+ height: 6px;
+ border-radius: 999px;
+ background: rgba(148, 163, 184, 0.22);
+ overflow: hidden;
+}
+
+.loadingbar__fill {
+ height: 100%;
+ background: var(--accent);
+ transition: width 0.2s linear;
+}
+
+/* ---------- ventanita con la pieza seleccionada ampliada ---------- */
+.zoom {
+ position: absolute;
+ /* Centrado y por encima de la barra de estado: así no tapa la lista de fases
+ (izquierda) ni la ficha (derecha), que en práctica ocupan los lados. */
+ left: 50%;
+ transform: translateX(-50%);
+ bottom: 84px;
+ width: 176px;
+ border-radius: 14px;
+ border: 1px solid var(--border);
+ background: var(--panel);
+ backdrop-filter: blur(12px);
+ overflow: hidden;
+ /* No estorba: no se queda con los clics del arrastre. */
+ pointer-events: none;
+ z-index: 11;
+}
+
+.zoom__view {
+ height: 132px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.zoom__img {
+ max-width: 92%;
+ max-height: 92%;
+ object-fit: contain;
+}
+
+.zoom__name {
+ display: block;
+ padding: 8px 10px;
+ border-top: 1px solid var(--border);
+ font-size: 12.5px;
+ color: var(--text);
+ text-align: center;
+}
+
/* ---------- barra de cables (fase de conectores) ---------- */
.connbar {
position: absolute;
@@ -930,6 +1036,43 @@ button {
margin-top: 4px;
}
+/* ---------- resumen del examen ---------- */
+.finish-review {
+ margin: 18px 0 0;
+ padding: 14px 16px;
+ border-radius: 14px;
+ border: 1px solid var(--border);
+ background: rgba(30, 41, 59, 0.35);
+}
+
+.finish-review h2 {
+ margin: 0 0 8px;
+ font-size: 13px;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+ color: var(--muted);
+}
+
+.finish-review__list {
+ margin: 0;
+ padding-left: 18px;
+ font-size: 14px;
+ line-height: 1.7;
+}
+
+.finish-review__list b {
+ color: var(--accent);
+}
+
+.finish-review__where {
+ color: var(--muted);
+ font-size: 13px;
+}
+
+.finish-review--unplaced .finish-review__list b {
+ color: var(--warning);
+}
+
.finish-score {
display: flex;
flex-direction: column;
diff --git a/src/store/useGameStore.ts b/src/store/useGameStore.ts
index 91e819e..d069175 100644
--- a/src/store/useGameStore.ts
+++ b/src/store/useGameStore.ts
@@ -5,10 +5,27 @@ import {
STAGE_STEPS,
TOTAL_STEPS,
shuffleLayout,
+ stageComponents,
} from '../data/components'
import { STAGES } from '../data/stages'
import type { GameMode, MountPoint, Phase, Stage, StageId, Vec3 } from '../types'
+/** Un fallo del examen: la pieza que se arrastró y el hueco donde se soltó. */
+export interface ExamMiss {
+ componentId: string
+ mountId: string
+}
+
+/**
+ * Pieza que quedó sin colocar al rendirse, con la fase en la que faltaba: la
+ * misma pieza puede faltar en dos fases (identificarla y montarla), y el resumen
+ * tiene que poder decirlo sin repetirse sin sentido.
+ */
+export interface UnplacedPiece {
+ stage: StageId
+ componentId: string
+}
+
interface State {
mode: GameMode
phase: Phase
@@ -23,9 +40,15 @@ interface State {
/** Piezas que se dejaron sin colocar al rendirse (solo examen). */
forfeited: number
wrongFlash: string | null
+ /** Fallos del examen, en orden, para el resumen final. */
+ misses: ExamMiss[]
+ /** Piezas que quedaron sin colocar al rendirse, para el resumen final. */
+ unplaced: UnplacedPiece[]
/** Marca de tiempo del último cambio de fase (para el aviso). */
stageChangedAt: number
lastCompletedStage: StageId | null
+ /** Cómo se llegó a la fase actual: completando la anterior, saltándola o empezando. */
+ lastAdvance: 'completed' | 'skipped' | null
startedAt: number
finishedAt: number | null
@@ -104,6 +127,7 @@ function advanceAfterPlace(placed: Record, stageIndex: number) {
hoverMountId: null,
stageChangedAt: Date.now(),
lastCompletedStage: STAGES[stageIndex].id,
+ lastAdvance: 'completed' as const,
}
}
@@ -120,8 +144,11 @@ export const useGameStore = create((set, get) => ({
attempts: 0,
forfeited: 0,
wrongFlash: null,
+ misses: [],
+ unplaced: [],
stageChangedAt: 0,
lastCompletedStage: null,
+ lastAdvance: null,
startedAt: 0,
finishedAt: null,
@@ -139,8 +166,11 @@ export const useGameStore = create((set, get) => ({
attempts: 0,
forfeited: 0,
wrongFlash: null,
+ misses: [],
+ unplaced: [],
stageChangedAt: 0,
lastCompletedStage: null,
+ lastAdvance: null,
startedAt: Date.now(),
finishedAt: null,
})
@@ -159,8 +189,11 @@ export const useGameStore = create((set, get) => ({
attempts: 0,
forfeited: 0,
wrongFlash: null,
+ misses: [],
+ unplaced: [],
stageChangedAt: 0,
lastCompletedStage: null,
+ lastAdvance: null,
startedAt: Date.now(),
finishedAt: null,
mode: s.mode,
@@ -185,6 +218,7 @@ export const useGameStore = create((set, get) => ({
wrongFlash: null,
stageChangedAt: Date.now(),
lastCompletedStage: STAGES[stageIndex].id,
+ lastAdvance: 'skipped' as const,
})
},
@@ -196,13 +230,19 @@ export const useGameStore = create((set, get) => ({
// lo mismo). Guardia silenciosa: sin confirmación visible.
if (mode !== 'exam' || phase !== 'building' || finishedAt !== null) return
if (Date.now() - stageChangedAt < 500) return
- const { placed, stageIndex, forfeited } = get()
+ const { placed, stageIndex, forfeited, unplaced } = get()
const stage = STAGES[stageIndex]
- const filled = placedInStage(placed, stageIndex).size
- const remaining = Math.max(0, STAGE_STEPS[stage.id] - filled)
+ const done = placedInStage(placed, stageIndex)
+ // La lista de lo que falta y su recuento salen de la misma fuente, así que no
+ // pueden discrepar.
+ const remaining = stageComponents(stage.id).filter((c) => !done.has(c.id))
const isLast = stageIndex >= STAGES.length - 1
set({
- forfeited: forfeited + remaining,
+ forfeited: forfeited + remaining.length,
+ unplaced: [
+ ...unplaced,
+ ...remaining.map((c) => ({ stage: stage.id, componentId: c.id })),
+ ],
selectedId: null,
dragging: false,
hoverMountId: null,
@@ -213,6 +253,7 @@ export const useGameStore = create((set, get) => ({
stageIndex: stageIndex + 1,
stageChangedAt: Date.now(),
lastCompletedStage: stage.id,
+ lastAdvance: 'skipped' as const,
}),
})
},
@@ -278,11 +319,16 @@ export const useGameStore = create((set, get) => ({
.filter((c) => c.d <= stage.drop.wrongRadius)
.sort((a, b) => a.d - b.d)
+ const failed = mode === 'exam' && wrong.length > 0
set({
dragging: false,
hoverMountId: null,
attempts: attempts + 1,
- errors: mode === 'exam' && wrong.length > 0 ? get().errors + 1 : get().errors,
+ errors: failed ? get().errors + 1 : get().errors,
+ // El resumen final necesita saber qué se falló y dónde, no solo cuántos.
+ misses: failed
+ ? [...get().misses, { componentId: selectedId, mountId: wrong[0].mount.id }]
+ : get().misses,
wrongFlash: wrong.length > 0 ? wrong[0].mount.id : null,
})
},
diff --git a/src/three/Scene.tsx b/src/three/Scene.tsx
index 8e4b172..af839e2 100644
--- a/src/three/Scene.tsx
+++ b/src/three/Scene.tsx
@@ -358,11 +358,18 @@ function DragController({ stage }: { stage: Stage }) {
else useGameStore.getState().updateDrag(hit.x, hit.z)
}
const onUp = () => useGameStore.getState().endDrag()
+ // El navegador puede quedarse con el gesto (desplazamiento, zoom, gesto del
+ // sistema) y cancelar el puntero a mitad: sin atender esa cancelación la
+ // pieza se queda colgada del cursor y el arrastre nunca termina. Cancelar
+ // suelta la pieza sin colocarla y sin contar fallo, como el Esc.
+ const onCancel = () => useGameStore.getState().cancelDrag()
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
+ window.addEventListener('pointercancel', onCancel)
return () => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
+ window.removeEventListener('pointercancel', onCancel)
}
}, [dragging, camera, gl, raycaster, stage])
diff --git a/src/ui/ComponentZoom.tsx b/src/ui/ComponentZoom.tsx
new file mode 100644
index 0000000..37fb59b
--- /dev/null
+++ b/src/ui/ComponentZoom.tsx
@@ -0,0 +1,49 @@
+import { Suspense } from 'react'
+import { Canvas } from '@react-three/fiber'
+import { connectorImageUrl } from '../data/assets'
+import { COMPONENT_BY_ID } from '../data/components'
+import { ComponentVisual } from '../three/ComponentModel'
+import type { GameMode } from '../types'
+
+/**
+ * Ventanita con la pieza seleccionada ampliada. En la fase 1 están todas sobre la
+ * mesa y algunas —los conectores— se ven muy pequeñas: aquí se ven enteras.
+ *
+ * Para los conectores con imagen propia se usa el PNG (más nítido que el dibujo
+ * 3D); para el resto, una vista 3D de cerca con la misma pieza que se arrastra.
+ *
+ * En examen no se escribe el nombre: decir qué pieza es también es una pista
+ * (misma regla que en la barra de estado).
+ */
+export function ComponentZoom({ id, mode }: { id: string; mode: GameMode }) {
+ const def = COMPONENT_BY_ID[id]
+ if (!def) return null
+
+ const image = def.category === 'conector' ? connectorImageUrl(def.kind) : undefined
+ // La pieza se dibuja a un tamaño parecido en la ventanita sea cual sea su
+ // `size` (los modelos se autoajustan a `def.size`; los conectores procedurales
+ // son más pequeños y se agrandan con este factor).
+ const scale = 1.15 / Math.max(0.12, def.size)
+
+ return (
+
+
+ {image ? (
+
+ ) : (
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ {mode === 'practice' &&
{def.name} }
+
+ )
+}
diff --git a/src/ui/ConnectorBar.tsx b/src/ui/ConnectorBar.tsx
index 98064e5..d22ddc8 100644
--- a/src/ui/ConnectorBar.tsx
+++ b/src/ui/ConnectorBar.tsx
@@ -40,12 +40,18 @@ export function ConnectorBar() {
beginDrag(def.id, 100, 100)
}
const onUp = () => stop()
+ // Si el navegador cancela el gesto (desplazamiento, zoom, gesto del
+ // sistema) no llega ningún `pointerup`: sin esto los escuchas se
+ // quedarían puestos hasta el siguiente gesto.
+ const onCancel = () => stop()
const stop = () => {
window.removeEventListener('pointermove', onMove)
window.removeEventListener('pointerup', onUp)
+ window.removeEventListener('pointercancel', onCancel)
}
window.addEventListener('pointermove', onMove)
window.addEventListener('pointerup', onUp)
+ window.addEventListener('pointercancel', onCancel)
}}
onClick={() => select(active ? null : def.id)}
>
diff --git a/src/ui/FinishModal.tsx b/src/ui/FinishModal.tsx
index 08cd173..ce14cae 100644
--- a/src/ui/FinishModal.tsx
+++ b/src/ui/FinishModal.tsx
@@ -1,6 +1,23 @@
-import { COMPONENTS, MOUNTS, TOTAL_STEPS } from '../data/components'
+import {
+ COMPONENT_BY_ID,
+ COMPONENTS,
+ IDENTIFY_LABEL,
+ MOUNTS,
+ MOUNT_BY_ID,
+ TOTAL_STEPS,
+ componentForMount,
+} from '../data/components'
import { STAGES } from '../data/stages'
import { examScore, scoreColor, useGameStore } from '../store/useGameStore'
+import type { StageId } from '../types'
+
+/** Cómo se llama cada fase en el resumen, en palabras y no en identificadores. */
+const STAGE_WORD: Record = {
+ identify: 'identificación',
+ board: 'sobre la placa',
+ ports: 'conectores',
+ peripherals: 'periféricos',
+}
function formatTime(ms: number): string {
const total = Math.max(0, Math.round(ms / 1000))
@@ -9,6 +26,12 @@ function formatTime(ms: number): string {
return `${m}:${s.toString().padStart(2, '0')}`
}
+/** Nombre corto y divulgativo de una pieza, el mismo que usan los carteles. */
+function pieceName(id: string): string {
+ const def = COMPONENT_BY_ID[id]
+ return def ? IDENTIFY_LABEL[def.kind] : id
+}
+
export function FinishModal() {
const mode = useGameStore((s) => s.mode)
const errors = useGameStore((s) => s.errors)
@@ -17,6 +40,8 @@ export function FinishModal() {
const startedAt = useGameStore((s) => s.startedAt)
const finishedAt = useGameStore((s) => s.finishedAt)
const attempts = useGameStore((s) => s.attempts)
+ const misses = useGameStore((s) => s.misses)
+ const unplaced = useGameStore((s) => s.unplaced)
const reset = useGameStore((s) => s.reset)
const backToMenu = useGameStore((s) => s.backToMenu)
@@ -76,6 +101,39 @@ export function FinishModal() {
: 'Practica de nuevo para bajar el tiempo.'}
+ {isExam && misses.length > 0 && (
+
+ Qué falló
+
+ {misses.map((miss, i) => {
+ const mount = MOUNT_BY_ID[miss.mountId]
+ const correct = mount ? componentForMount(mount) : undefined
+ return (
+
+ Pusiste «{pieceName(miss.componentId)}» en{' '}
+ «{mount?.label ?? 'otro sitio'}» → ahí va{' '}
+ «{correct ? IDENTIFY_LABEL[correct.kind] : 'otra pieza'}»
+
+ )
+ })}
+
+
+ )}
+
+ {isExam && unplaced.length > 0 && (
+
+ Te quedó por colocar
+
+ {unplaced.map((item) => (
+
+ {pieceName(item.componentId)} {' '}
+ ({STAGE_WORD[item.stage]})
+
+ ))}
+
+
+ )}
+
Menú
diff --git a/src/ui/LoadingBar.tsx b/src/ui/LoadingBar.tsx
new file mode 100644
index 0000000..01c0052
--- /dev/null
+++ b/src/ui/LoadingBar.tsx
@@ -0,0 +1,34 @@
+import { useProgress } from '@react-three/drei'
+
+/**
+ * Aviso de carga de los modelos 3D. El `.glb` de la placa pesa hasta 18,8 MB, así
+ * que en el aula tarda: sin esto, lo único que se veía era el placeholder
+ * procedural, que aparecía y luego se sustituía sin explicar nada.
+ *
+ * No bloquea: es una franja sobre el lienzo con `pointer-events: none`, así que
+ * se puede seguir mirando la escena mientras llega el modelo.
+ */
+export function LoadingBar() {
+ const active = useProgress((s) => s.active)
+ const progress = useProgress((s) => s.progress)
+ const item = useProgress((s) => s.item)
+
+ if (!active) return null
+
+ // El porcentaje del gestor de carga de three puede salirse de rango al
+ // encadenar cargas, así que se acota antes de pintarlo.
+ const pct = Math.min(100, Math.max(0, Math.round(Number.isFinite(progress) ? progress : 0)))
+ const label = item.includes('/placas/')
+ ? 'Cargando el modelo de la placa…'
+ : 'Cargando los modelos 3D…'
+
+ return (
+
+ )
+}
diff --git a/src/ui/StageBanner.tsx b/src/ui/StageBanner.tsx
index ee6b374..b39ad92 100644
--- a/src/ui/StageBanner.tsx
+++ b/src/ui/StageBanner.tsx
@@ -1,25 +1,43 @@
import { useEffect, useState } from 'react'
+import { STAGES } from '../data/stages'
import { useCurrentStage, useGameStore } from '../store/useGameStore'
-/** Aviso central que aparece al completar una fase y pasar a la siguiente. */
+/**
+ * Cartel central de la fase: al **empezarla** dice qué hay que hacer en ella y,
+ * cuando se llega a ella completando la anterior, lo avisa («Fase completada»).
+ *
+ * En examen no se monta: el examen no da explicaciones (decisión 51). El cartel
+ * no intercepta clics (`pointer-events: none` en la hoja de estilos), así que
+ * puede estar visible mientras se arrastra.
+ */
export function StageBanner() {
- const stageChangedAt = useGameStore((s) => s.stageChangedAt)
+ const mode = useGameStore((s) => s.mode)
+ const phase = useGameStore((s) => s.phase)
+ const startedAt = useGameStore((s) => s.startedAt)
+ const stageIndex = useGameStore((s) => s.stageIndex)
+ const lastAdvance = useGameStore((s) => s.lastAdvance)
const stage = useCurrentStage()
- const [dismissedAt, setDismissedAt] = useState(0)
+ const [dismissedKey, setDismissedKey] = useState('')
- const visible = stageChangedAt > 0 && stageChangedAt > dismissedAt
+ // La clave lleva la partida y la fase: el cartel sale al empezar cada fase
+ // (también la primera, al arrancar o al reiniciar) y no se repite mientras no
+ // cambie ninguna de las dos.
+ const key = `${startedAt}-${stageIndex}`
+ const visible = phase === 'building' && mode === 'practice' && key !== dismissedKey
useEffect(() => {
if (!visible) return
- const id = window.setTimeout(() => setDismissedAt(stageChangedAt), 2800)
+ const id = window.setTimeout(() => setDismissedKey(key), 3600)
return () => window.clearTimeout(id)
- }, [visible, stageChangedAt])
+ }, [visible, key])
if (!visible) return null
return (
-
Fase completada
+
+ {lastAdvance === 'completed' ? 'Fase completada' : `Fase ${stage.index + 1} de ${STAGES.length}`}
+
{stage.index + 1}. {stage.title}
diff --git a/src/ui/StatusBar.tsx b/src/ui/StatusBar.tsx
index 671b9cd..23797a3 100644
--- a/src/ui/StatusBar.tsx
+++ b/src/ui/StatusBar.tsx
@@ -21,9 +21,15 @@ export function StatusBar({ raised = false }: { raised?: boolean }) {
text = 'Ese no es su sitio. ¡Fíjate bien!'
tone = 'status-danger'
} else if (dragging && hoverMountId) {
- const mount = MOUNTS_BY_STAGE[STAGES[stageIndex].id].find((m) => m.id === hoverMountId)
- text = `Suelta aquí: ${mount?.label ?? ''}`
- tone = 'status-ok'
+ // En examen, nombrar el hueco compatible es la respuesta: el aviso se queda
+ // para la práctica y en examen solo se dice la instrucción de siempre.
+ if (mode === 'practice') {
+ const mount = MOUNTS_BY_STAGE[STAGES[stageIndex].id].find((m) => m.id === hoverMountId)
+ text = `Suelta aquí: ${mount?.label ?? ''}`
+ tone = 'status-ok'
+ } else {
+ text = 'Lleva la pieza hasta el hueco correcto.'
+ }
} else if (dragging) {
text = 'Lleva la pieza hasta el hueco correcto.'
} else if (selectedId) {
@@ -38,7 +44,8 @@ export function StatusBar({ raised = false }: { raised?: boolean }) {
? `${def.name}: colócala en «${targets}». Arrástrala o haz clic en la zona iluminada.`
: `${def.name}: esta pieza no encaja en ningún sitio. Déjala en la bandeja.`
} else {
- text = `${def.name} seleccionado. Arrástralo hasta su sitio.`
+ // Sin el nombre: en examen decir qué pieza es también es una pista.
+ text = 'Pieza seleccionada. Arrástrala hasta su sitio.'
}
tone = 'status-ok'
}
diff --git a/tests/run.mjs b/tests/run.mjs
index 51f5281..9114183 100644
--- a/tests/run.mjs
+++ b/tests/run.mjs
@@ -4,7 +4,19 @@
* npm run dev -- --port 5199 # en otra terminal
* node tests/run.mjs
*
- * Variables: BASE_URL (por defecto http://localhost:5199), CHROME (ruta al binario).
+ * Variables: BASE_URL (por defecto http://localhost:5199), CHROME (ruta al binario;
+ * si no se define, se busca en las cachés de puppeteer y Playwright y en las
+ * instalaciones habituales de Chrome), ONLY (lista de secciones separadas por
+ * comas; acota la pasada a esas, que es lo que hace viable comprobar una
+ * falsación sin esperar los ~11 minutos de la pasada completa; un nombre que no
+ * exista para la pasada con error en vez de decir «TODO OK» sin ejecutar nada).
+ *
+ * Aviso: si has tocado `src/`, **reinicia el servidor de desarrollo** antes de
+ * correr el arnés. Con HMR, el import dinámico de `/src/store/useGameStore.ts`
+ * puede devolver una COPIA distinta de la que usa la app (Vite añade `?t=…` al
+ * módulo invalidado), y entonces las acciones del store no llegan a la partida:
+ * las secciones que las usan fallan de forma desconcertante (§7, §12 y §13 usan
+ * ese import; §14 y §15 van por la interfaz, así que no les afecta).
* Se usa `localhost` y no `127.0.0.1` porque Vite 8 puede escuchar solo en IPv6
* (`[::1]`); si la instancia de desarrollo escucha solo en IPv4, pásale
* BASE_URL=http://127.0.0.1:5199.
@@ -21,19 +33,105 @@ const ITEMS = 36
/** Colocaciones de toda la partida (identificación + montaje + cables + periféricos). */
const PLACEABLE = 35
+/**
+ * Ruta a un Chromium usable. Antes solo miraba la caché de puppeteer en Linux,
+ * así que en Windows el arnés no arrancaba sin pasar CHROME a mano. Ahora busca
+ * en la caché de puppeteer (las tres plataformas), en los navegadores de
+ * Playwright y en las instalaciones habituales de Chrome.
+ */
function findChrome() {
if (process.env.CHROME) return process.env.CHROME
- const root = join(homedir(), '.cache', 'puppeteer', 'chrome')
- if (!existsSync(root)) throw new Error('No encuentro Chromium. Define CHROME=
.')
- for (const dir of readdirSync(root)) {
- const bin = join(root, dir, 'chrome-linux64', 'chrome')
- if (existsSync(bin)) return bin
+
+ const candidates = []
+ const add = (...parts) => candidates.push(join(...parts))
+
+ // Caché de puppeteer (por defecto ~/.cache/puppeteer/chrome).
+ const puppeteerCache =
+ process.env.PUPPETEER_CACHE_DIR ?? join(homedir(), '.cache', 'puppeteer', 'chrome')
+ if (existsSync(puppeteerCache)) {
+ for (const dir of readdirSync(puppeteerCache)) {
+ add(puppeteerCache, dir, 'chrome-linux64', 'chrome')
+ add(puppeteerCache, dir, 'chrome-linux', 'chrome')
+ add(puppeteerCache, dir, 'chrome-win64', 'chrome.exe')
+ add(puppeteerCache, dir, 'chrome-win', 'chrome.exe')
+ add(puppeteerCache, dir, 'chrome-mac-arm64', 'Chromium.app', 'Contents', 'MacOS', 'Chromium')
+ add(puppeteerCache, dir, 'chrome-mac-x64', 'Chromium.app', 'Contents', 'MacOS', 'Chromium')
+ }
+ }
+
+ // Navegadores de Playwright, si están instalados (`npx playwright install`).
+ const playwrightCache =
+ process.env.PLAYWRIGHT_BROWSERS_PATH ??
+ (process.platform === 'win32'
+ ? join(process.env.LOCALAPPDATA ?? homedir(), 'ms-playwright')
+ : process.platform === 'darwin'
+ ? join(homedir(), 'Library', 'Caches', 'ms-playwright')
+ : join(homedir(), '.cache', 'ms-playwright'))
+ if (existsSync(playwrightCache)) {
+ for (const dir of readdirSync(playwrightCache).filter((d) => d.startsWith('chromium-'))) {
+ add(playwrightCache, dir, 'chrome-linux', 'chrome')
+ add(playwrightCache, dir, 'chrome-linux64', 'chrome')
+ add(playwrightCache, dir, 'chrome-win64', 'chrome.exe')
+ add(playwrightCache, dir, 'chrome-win', 'chrome.exe')
+ add(playwrightCache, dir, 'chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium')
+ }
+ }
+
+ // Chrome instalado en su sitio de siempre.
+ if (process.platform === 'win32') {
+ const local = process.env.LOCALAPPDATA ?? ''
+ add('C:', 'Program Files', 'Google', 'Chrome', 'Application', 'chrome.exe')
+ add('C:', 'Program Files (x86)', 'Google', 'Chrome', 'Application', 'chrome.exe')
+ add(local, 'Google', 'Chrome', 'Application', 'chrome.exe')
+ } else if (process.platform === 'darwin') {
+ add('/Applications', 'Google Chrome.app', 'Contents', 'MacOS', 'Google Chrome')
+ } else {
+ add('/usr', 'bin', 'google-chrome')
+ add('/usr', 'bin', 'chromium')
+ add('/usr', 'bin', 'chromium-browser')
}
- throw new Error('No encuentro el binario de Chromium. Define CHROME=.')
+
+ const found = candidates.find((path) => existsSync(path))
+ if (found) return found
+ throw new Error(
+ 'No encuentro Chromium. Define CHROME=. Se ha buscado en:\n ' +
+ candidates.join('\n '),
+ )
}
const wait = (ms) => new Promise((r) => setTimeout(r, ms))
+/**
+ * ¿El elemento existe Y ocupa sitio en pantalla? La presencia en el DOM no dice
+ * nada: un panel plegado o una tarjeta sin montar siguen existiendo en el HTML.
+ */
+async function isVisible(page, selector) {
+ return page
+ .$$eval(selector, (els) =>
+ els.some((el) => {
+ const r = el.getBoundingClientRect()
+ return r.width > 0 && r.height > 0
+ }),
+ )
+ .catch(() => false)
+}
+
+/**
+ * Pulsa el primer botón de la barra superior cuyo texto empiece por `text`.
+ * Preferir esto al import dinámico del store: el botón es lo que pulsa una
+ * persona y no depende de qué instancia del módulo tenga la app.
+ */
+async function clickButton(page, text) {
+ for (const handle of await page.$$('.topbar button')) {
+ const label = ((await handle.evaluate((el) => el.textContent ?? '')) ?? '').trim()
+ if (label.startsWith(text)) {
+ await handle.click()
+ return true
+ }
+ }
+ return false
+}
+
async function newPage(browser, url, seed) {
const page = await browser.newPage()
await page.setViewport({ width: 1400, height: 900 })
@@ -126,6 +224,68 @@ const check = (name, ok, detail = '') => {
if (!ok) failures++
}
+/**
+ * Antes de nada: en BASE_URL tiene que haber un simulador vivo. Si el servidor no
+ * está, o está en otro puerto (Vite se pasa al siguiente en silencio cuando el
+ * puerto está ocupado), todas las secciones fallarían con errores que parecen del
+ * código. Se para con un mensaje claro en vez de con veinte fallos raros.
+ */
+async function requireServer() {
+ try {
+ const res = await fetch(BASE_URL)
+ const html = await res.text()
+ if (!res.ok || !html.includes('id="root"')) throw new Error(`HTTP ${res.status}`)
+ } catch (e) {
+ console.error(`No hay simulador en ${BASE_URL} (${e.message}).`)
+ console.error(
+ 'Levanta `npm run dev -- --port 5199` y comprueba la línea «Local:» del log: ' +
+ 'si el puerto estaba ocupado, Vite se pasa a 5200 sin avisar.',
+ )
+ process.exit(1)
+ }
+}
+
+await requireServer()
+
+/**
+ * Varias secciones mueven la partida llamando al store con un import dinámico
+ * (`/src/store/useGameStore.ts`). Si se ha tocado `src/` con el servidor de
+ * desarrollo levantado, HMR deja ese módulo con `?t=…` y el import devuelve una
+ * COPIA distinta de la que usa la app: las acciones no llegan a la partida y el
+ * arnés falla como si fuese un defecto del producto (§7, §8, §12 y §13).
+ *
+ * Esto lo detecta antes de correr nada: se mueve el store desde el import y se
+ * comprueba que la app lo refleja en el DOM. Si no, se para con el motivo.
+ */
+async function requireSameStoreInstance() {
+ const page = await browser.newPage()
+ try {
+ await page.goto(BASE_URL, { waitUntil: 'networkidle0', timeout: 180000 })
+ await page.waitForSelector('button.mode-card', { timeout: 60000 })
+ const cards = await page.$$('button.mode-card')
+ await cards[0].click() // práctica
+ await page.waitForSelector('.badge--stage', { timeout: 60000 })
+ await wait(400)
+ const antes = await page.$eval('.badge--stage', (el) => el.textContent.trim())
+ await page.evaluate(() =>
+ import('/src/store/useGameStore.ts').then((m) => m.useGameStore.getState().skipStage()),
+ )
+ await wait(700)
+ const despues = await page.$eval('.badge--stage', (el) => el.textContent.trim())
+ if (antes === despues) {
+ console.error(
+ `El import dinámico del store NO es la misma instancia que la de la app (${antes} → ${despues}).`,
+ )
+ console.error(
+ 'Pasa al tocar `src/` con el servidor levantado: reinícialo antes de correr el arnés (HMR deja el módulo con ?t=…).',
+ )
+ process.exit(1)
+ }
+ } finally {
+ await page.close()
+ }
+}
+
const browser = await puppeteer.launch({
executablePath: findChrome(),
headless: true,
@@ -137,6 +297,8 @@ const browser = await puppeteer.launch({
],
})
+await requireSameStoreInstance()
+
/* ---------- 1. Modo calibración ---------- */
{
const { page, errors } = await newPage(browser, `${BASE_URL}/?calibrate=1`)
@@ -956,7 +1118,8 @@ async function examNoHelp() {
check('examen sin ayudas: no hay lista de piezas', !(await page.$('.comp-item')))
// La leyenda de controles y el aviso de la barra de estado NO son la
// respuesta, así que se quedan (decisión de producto).
- check('examen sin ayudas: la leyenda de controles se queda', !!(await page.$('.help')))
+ check('examen sin ayudas: la leyenda de controles se ve', await isVisible(page, '.help__card'))
+ check('examen sin ayudas: el botón de la leyenda se ve', await isVisible(page, '.help__toggle'))
// 2) Nada de imán: en examen el fantasma sigue al puntero aunque esté sobre
// el hueco correcto. Se comprueba sobre el módulo que usa el renderer
@@ -995,6 +1158,39 @@ async function examNoHelp() {
`${JSON.stringify(snap.posExamen)} vs puntero ${JSON.stringify(snap.puntero)}`,
)
+ // La comprobación anterior solo vale si puede fallar: con la leyenda
+ // recordada como oculta, la tarjeta NO debe verse (y el botón sí, que es como
+ // se vuelve a abrir). Sin este caso, un `isVisible` que devolviera siempre
+ // `true` pasaría igual.
+ {
+ const oculta = await newPage(browser, `${BASE_URL}/?board=motherboard-01`, () =>
+ localStorage.setItem('pc-sim-help', 'hidden'),
+ )
+ await oculta.page.waitForSelector('button.mode-card', { timeout: 60000 })
+ const cards = await oculta.page.$$('button.mode-card')
+ if (cards.length >= 2) {
+ await cards[1].click()
+ await oculta.page.waitForSelector('.topbar', { timeout: 60000 })
+ await wait(800)
+ check(
+ 'examen sin ayudas: con la leyenda oculta, la tarjeta no se ve',
+ !(await isVisible(oculta.page, '.help__card')),
+ )
+ check(
+ 'examen sin ayudas: con la leyenda oculta, el botón sigue viéndose',
+ await isVisible(oculta.page, '.help__toggle'),
+ )
+ } else {
+ check('examen sin ayudas: la leyenda oculta arranca', false, `${cards.length} tarjetas`)
+ }
+ check(
+ 'examen sin ayudas: leyenda oculta sin errores',
+ oculta.errors.length === 0,
+ oculta.errors.join(' | '),
+ )
+ await oculta.page.close()
+ }
+
check('examen sin ayudas: sin errores', errors.length === 0, errors.join(' | '))
await page.close()
}
@@ -1028,19 +1224,39 @@ async function traySeparation() {
const at = (d) => (stage === 'identify' ? d.identifyPos : d.trayPos)
let worst = Infinity
let bad = 0
+ let worstCol = Infinity
+ let malaCol = 0
for (let run = 0; run < runs; run++) {
shuffleLayout()
for (let i = 0; i < defs.length; i++) {
for (let j = i + 1; j < defs.length; j++) {
const a = at(defs[i])
const b = at(defs[j])
- const sep = Math.max(Math.abs(a[0] - b[0]), Math.abs(a[1] - b[1]))
+ const dx = Math.abs(a[0] - b[0])
+ const dz = Math.abs(a[1] - b[1])
+ const sep = Math.max(dx, dz)
if (sep < worst) worst = sep
if (sep < 0.75) bad++
+ // Misma «columna visual»: la cámara de la fase 1 mira en oblicuo, así
+ // que dos piezas con la misma X se dibujan una detrás de otra. Si
+ // además quedan cerca en profundidad, parecen estar en el mismo
+ // sitio (es lo que se reportó con los tres jacks y el USB-C, que son
+ // los más pequeños). El suelo de 1,3 u son ~110 px en pantalla;
+ // con la rejilla vieja (4 filas) eran 0,9 u (~71 px).
+ if (dx < 0.7) {
+ if (dz < worstCol) worstCol = dz
+ if (dz < 1.3) malaCol++
+ }
}
}
}
- out[stage] = { piezas: defs.length, peor: +worst.toFixed(3), solapados: bad }
+ out[stage] = {
+ piezas: defs.length,
+ peor: +worst.toFixed(3),
+ solapados: bad,
+ peorColumna: Number.isFinite(worstCol) ? +worstCol.toFixed(3) : null,
+ columnaCorta: malaCol,
+ }
}
return out
},
@@ -1055,6 +1271,14 @@ async function traySeparation() {
`${r.piezas} piezas, separación mínima ${r.peor} u (mínimo ${SPAN}) y ${r.solapados} pares por debajo`,
)
}
+ // Además de no solaparse, en la fase 1 (donde están TODAS las piezas a la vez)
+ // ninguna debe esconderse detrás de otra en la misma columna visual.
+ const id = result.identify
+ check(
+ 'mesa: en la fase 1 ninguna pieza se esconde en la misma columna visual',
+ id.columnaCorta === 0 && id.peorColumna >= 1.3,
+ `misma columna: separación mínima en profundidad ${id.peorColumna} u (mínimo 1.3) y ${id.columnaCorta} pares por debajo`,
+ )
// La identificación es la fase que se ve entera sobre la mesa y la que se
// reportó con las clavijas de audio encima unas de otras. Esta comprobación no
// es un invariante de comportamiento sino un **suelo de diseño**: fija que la
@@ -1125,6 +1349,17 @@ async function examDrag() {
}),
)
const scoreNumber = (text) => Number(text.replace(/\D/g, ''))
+ const status = () =>
+ page.$eval('.statusbar span:last-child', (el) => el.textContent.trim()).catch(() => '')
+ /** Primer cable de la barra cuyo texto contenga `name` (los índices cambian
+ * cuando un cable ya colocado sale de la barra). */
+ const barItemByName = async (name) => {
+ for (const item of await page.$$('.connbar__item')) {
+ const text = ((await item.evaluate((el) => el.textContent)) ?? '').trim()
+ if (text.includes(name)) return item
+ }
+ return null
+ }
// Arrastra el cable de la posición i de la barra hasta un punto de la pantalla.
const dragTo = async (i, point) => {
@@ -1139,9 +1374,12 @@ async function examDrag() {
await page.mouse.move(point.x, point.y)
await wait(400)
const hover = (await store()).hover
+ // Lo que dice la barra de estado mientras el puntero está encima del hueco:
+ // en examen no puede revelar cuál es.
+ const estado = await status()
await page.mouse.up()
await wait(700)
- return { ok: true, hover }
+ return { ok: true, hover, estado }
}
// El nombre del cable señuelo, tal y como lo llama el catálogo (no se escribe a
@@ -1164,6 +1402,14 @@ async function examDrag() {
await wait(3500)
const stage = await page.$eval('.badge--stage', (el) => el.textContent.trim()).catch(() => '')
check('examen arrastre: en la fase de conectores', stage === '3. Conectores', stage)
+ if (stage !== '3. Conectores') {
+ // Sin la fase de conectores no hay barra ni puertos que medir y seguir daría un
+ // error de tipo que tumba el arnés entero. Casi siempre es que el import
+ // dinámico del store no es la misma instancia que la de la app por HMR
+ // (reinicia el servidor de desarrollo).
+ await page.close()
+ return
+ }
const labels = await names()
check(
@@ -1302,21 +1548,818 @@ async function examDrag() {
afterGoodScore === afterWrongScore,
`${afterWrongScore} → ${afterGoodScore}`,
)
+
+ // 3) La barra de estado no da pistas en examen: ni el hueco compatible al pasar
+ // por encima, ni el nombre de la pieza al seleccionarla (decisión de la
+ // persona usuaria).
+ check(
+ 'examen arrastre: la barra no revela el hueco al pasar por encima',
+ goodDrop.ok && !/Suelta aquí/.test(goodDrop.estado ?? ''),
+ `«${goodDrop.estado ?? ''}»`,
+ )
+ const decoyItem = await barItemByName(decoy.name)
+ check('examen arrastre: el señuelo sigue en la barra para seleccionarlo', !!decoyItem)
+ if (decoyItem) {
+ await decoyItem.click()
+ await wait(400)
+ const estadoSeleccion = await status()
+ check(
+ 'examen arrastre: la barra no nombra la pieza seleccionada',
+ estadoSeleccion.length > 0 && !estadoSeleccion.includes(decoy.name),
+ `«${estadoSeleccion}» no debe contener «${decoy.name}»`,
+ )
+ }
check('examen arrastre: sin errores de página', errors.length === 0, errors.join(' | '))
await page.close()
}
-await fullGame()
-await examGiveUpGuard()
-await skipStageGuard()
-await unknownBoardGuard()
-await rotationsGuard()
-await unknownBoardNoCalibration()
-await dragGhostVisible()
-await sidePanels()
-await examNoHelp()
-await traySeparation()
-await examDrag()
+/* ---------- 13. El arrastre con el dedo (y un gesto que el navegador cancela) ---------- */
+async function touchDrag() {
+ // Dos cosas que no se habían probado nunca: el arrastre con un dedo (hasta
+ // ahora el arnés solo usaba `page.mouse`) y un gesto que el navegador cancela
+ // a mitad (desplazamiento, zoom, gesto del sistema). Sin atender el
+ // `pointercancel`, la pieza se queda colgada del cursor y el arrastre no
+ // termina nunca: solo el Esc la suelta.
+ //
+ // Se arrastra desde la barra de cables porque es un elemento del DOM con
+ // coordenadas estables: no hace falta proyectar una malla 3D para saber dónde
+ // hay que soltar.
+ //
+ // El gesto se emula por CDP (`Input.dispatchTouchEvent`) y no con
+ // `page.touchscreen`, que no puede emitir la cancelación.
+ const { page, errors } = await newPage(browser, `${BASE_URL}/?board=motherboard-01`)
+ await page.setViewport({ width: 1400, height: 900, hasTouch: true })
+ const client = await page.createCDPSession()
+
+ const dispatch = (type, points) =>
+ client.send('Input.dispatchTouchEvent', { type, touchPoints: points })
+ const fingerDown = (p) => dispatch('touchStart', [{ x: p.x, y: p.y }])
+ const fingerMove = (p) => dispatch('touchMove', [{ x: p.x, y: p.y }])
+ const fingerUp = () => dispatch('touchEnd', [])
+ const fingerCancel = () => dispatch('touchCancel', [])
+
+ const menuCards = await page
+ .waitForSelector('button.mode-card', { timeout: 60000 })
+ .then(() => page.$$('button.mode-card'))
+ .catch(() => [])
+ check('táctil: menú de modos visible', menuCards.length >= 2, `${menuCards.length}`)
+ if (menuCards.length < 2) {
+ await page.close()
+ return
+ }
+ await menuCards[0].click() // práctica: las zonas iluminadas dicen dónde cae el puerto
+ await page.waitForSelector('.topbar', { timeout: 60000 })
+ await wait(500)
+
+ const skip = () =>
+ page.evaluate(() =>
+ import('/src/store/useGameStore.ts').then((m) => m.useGameStore.getState().skipStage()),
+ )
+ const progress = () =>
+ page.$eval('.progress-label', (el) => el.textContent.trim()).catch(() => '')
+ const status = () =>
+ page.$eval('.statusbar span:last-child', (el) => el.textContent.trim()).catch(() => '')
+ const store = () =>
+ page.evaluate(() =>
+ import('/src/store/useGameStore.ts').then((m) => {
+ const s = m.useGameStore.getState()
+ return { dragging: s.dragging, attempts: s.attempts, placed: Object.keys(s.placed).length }
+ }),
+ )
+
+ await skip()
+ await wait(1200)
+ await skip()
+ await wait(3500)
+ const stage = await page.$eval('.badge--stage', (el) => el.textContent.trim()).catch(() => '')
+ check('táctil: en la fase de conectores', stage === '3. Conectores', stage)
+
+ // El gesto tiene que ser del juego también de forma PREVENTIVA, no solo
+ // reaccionar al `pointercancel` una vez el navegador ya se lo ha quedado. Se
+ // mira el estilo COMPUTADO y no la hoja de estilos: `OrbitControls`
+ // (three-stdlib) escribe un `touch-action` en línea sobre el canvas al
+ // desmontarse y el estilo en línea gana a una clase, así que la regla estaba
+ // puesta pero era inerte (defecto real encontrado por la verificación
+ // independiente).
+ const gestureOwner = (selector) =>
+ page.$eval(selector, (el) => getComputedStyle(el).touchAction).catch(() => null)
+ const canvasTouch = await gestureOwner('.canvas-host canvas')
+ check(
+ 'táctil: el lienzo no deja el gesto al navegador',
+ canvasTouch === 'none',
+ `touch-action: ${canvasTouch}`,
+ )
+ const barTouch = await gestureOwner('.connbar__item')
+ check(
+ 'táctil: la barra de cables tampoco deja el gesto',
+ barTouch === 'none',
+ `touch-action: ${barTouch}`,
+ )
+
+ // Elige el primer cable de la barra (un clic normal, que solo selecciona) para
+ // que se iluminen sus puertos y poder leer en pantalla dónde cae uno.
+ const pickCableAndPort = async () => {
+ const item = (await page.$$('.connbar__item'))[0]
+ if (!item) return null
+ await item.click()
+ await wait(400)
+ const box = await item.boundingBox()
+ const port = await page
+ .$eval('.mount-center', (el) => {
+ const r = el.getBoundingClientRect()
+ return { x: r.x + r.width / 2, y: r.y + r.height / 2 }
+ })
+ .catch(() => null)
+ if (!box || !port) return null
+ return { bar: { x: box.x + box.width / 2, y: box.y + box.height / 2 }, port }
+ }
+
+ // Arrastra con el dedo desde la barra hasta el puerto y termina como se pida.
+ const dragWithFinger = async (first, finish) => {
+ await fingerDown(first.bar)
+ await wait(150)
+ // La barra solo empieza el arrastre cuando el puntero se mueve de verdad.
+ await fingerMove({ x: first.bar.x + 14, y: first.bar.y - 14 })
+ await wait(250)
+ const started = (await store()).dragging
+ await fingerMove(first.port)
+ await wait(300)
+ await finish()
+ await wait(500)
+ return started
+ }
+
+ // 1) Control positivo: con el dedo, el cable se coloca. Sin esto, la prueba de
+ // la cancelación pasaría en vacío (si el arrastre táctil no funcionara no
+ // habría nada que cancelar).
+ const first = await pickCableAndPort()
+ check('táctil: hay un cable y un puerto en pantalla', !!first)
+ if (!first) {
+ await page.close()
+ return
+ }
+ const beforeTouch = { progress: await progress(), ...(await store()) }
+ const startedTouch = await dragWithFinger(first, fingerUp)
+ check('táctil: el dedo empieza el arrastre', startedTouch === true)
+ const afterTouch = { progress: await progress(), ...(await store()) }
+ check(
+ 'táctil: el dedo coloca el cable',
+ afterTouch.placed === beforeTouch.placed + 1,
+ `${beforeTouch.placed} → ${afterTouch.placed} colocadas`,
+ )
+ check(
+ 'táctil: el progreso avanza con el dedo',
+ afterTouch.progress !== beforeTouch.progress,
+ `${beforeTouch.progress} → ${afterTouch.progress}`,
+ )
+
+ // 2) El gesto cancelado: el navegador se queda con el dedo y el arrastre tiene
+ // que soltarse sin colocar la pieza y sin contarlo como una suelta.
+ const second = await pickCableAndPort()
+ check('táctil: hay un segundo cable para el caso cancelado', !!second)
+ if (second) {
+ const beforeCancel = { progress: await progress(), status: await status(), ...(await store()) }
+ const startedCancel = await dragWithFinger(second, fingerCancel)
+ const afterCancel = { progress: await progress(), status: await status(), ...(await store()) }
+ check('táctil: el dedo vuelve a empezar el arrastre', startedCancel === true)
+ check(
+ 'táctil: el gesto cancelado suelta la pieza (no se queda colgada)',
+ afterCancel.dragging === false,
+ `dragging=${afterCancel.dragging}`,
+ )
+ check(
+ 'táctil: el gesto cancelado no coloca nada',
+ afterCancel.placed === beforeCancel.placed,
+ `${beforeCancel.placed} → ${afterCancel.placed} colocadas`,
+ )
+ check(
+ 'táctil: el gesto cancelado no cuenta como intento',
+ afterCancel.attempts === beforeCancel.attempts,
+ `${beforeCancel.attempts} → ${afterCancel.attempts}`,
+ )
+ check(
+ 'táctil: el progreso no se mueve al cancelar',
+ afterCancel.progress === beforeCancel.progress,
+ `${beforeCancel.progress} → ${afterCancel.progress}`,
+ )
+ check(
+ 'táctil: al cancelar, la barra de estado deja de arrastrar',
+ !/Suelta aquí|Lleva la pieza/.test(afterCancel.status),
+ `«${beforeCancel.status}» → «${afterCancel.status}»`,
+ )
+ }
+
+ check('táctil: sin errores de página', errors.length === 0, errors.join(' | '))
+ await page.close()
+}
+
+/* ---------- 14. El resumen del examen dice qué se falló y qué quedó sin colocar ---------- */
+async function examSummary() {
+ // El resumen se construye con datos que antes no existían (`misses`, `unplaced`),
+ // así que la prueba ejerce el camino real: un fallo de verdad y dos «Rendirse»
+ // de verdad, y luego mira la tarjeta final como la mira el alumnado.
+ //
+ // // Lo que fija: una fila por fallo, que la fila nombra la pieza arrastrada, que
+ // el sitio nombrado es un puerto real de la fase, que la pieza que va ahí no es
+ // la que se arrastró, y que el número de filas sin colocar es el mismo que el
+ // descuento. NO fija la identidad exacta del puerto (eso lo decide el radio de
+ // error del store, y apuntarlo con precisión no es determinista en la chapa) ni
+ // que el resumen sea exclusivo del examen: en práctica no se puede fallar ni
+ // rendirse, así que esa guarda no es observable desde la interfaz.
+ const { page, errors } = await newPage(browser, `${BASE_URL}/?board=motherboard-01`)
+ const menuCards = await page
+ .waitForSelector('button.mode-card', { timeout: 60000 })
+ .then(() => page.$$('button.mode-card'))
+ .catch(() => [])
+ check('resumen examen: menú de modos visible', menuCards.length >= 2, `${menuCards.length}`)
+ if (menuCards.length < 2) {
+ await page.close()
+ return
+ }
+ await menuCards[0].click() // práctica: las zonas iluminadas dicen dónde cae el puerto
+ await page.waitForSelector('.topbar', { timeout: 60000 })
+ await wait(500)
+
+ // El catálogo se lee del módulo (datos estáticos, no del store), pero si esa
+ // lectura falla —pasa si el servidor se muere a mitad— la sección falla en vez
+ // de tumbar todo el arnés.
+ const catalog = async () => {
+ try {
+ return await page.evaluate(() =>
+ import('/src/data/components.ts').then((m) => {
+ const decoyDef = m.COMPONENTS_BY_STAGE.ports.find((c) => c.decoy)
+ return {
+ decoy: decoyDef ? { name: m.IDENTIFY_LABEL[decoyDef.kind] } : null,
+ portLabels: m.MOUNTS_BY_STAGE.ports.map((mo) => mo.label),
+ }
+ }),
+ )
+ } catch (e) {
+ return { decoy: null, portLabels: [], error: e.message }
+ }
+ }
+ const labels = () => page.$$eval('.connbar__item', (els) => els.map((e) => e.textContent.trim()))
+ const rowsOf = (selector) =>
+ page.$$eval(selector, (els) =>
+ els.map((el) => (el.textContent ?? '').replace(/\s+/g, ' ').trim()),
+ )
+
+ // Arrastra el cable de la barra en la posición `index` hasta un punto de la
+ // pantalla (mismo gesto que §12).
+ const dragTo = async (index, point) => {
+ const item = (await page.$$('.connbar__item'))[index]
+ if (!item) return false
+ const box = await item.boundingBox()
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
+ await page.mouse.down()
+ await wait(250)
+ await page.mouse.move(box.x + box.width / 2 + 40, box.y + box.height / 2 - 40)
+ await wait(250)
+ await page.mouse.move(point.x, point.y)
+ await wait(400)
+ await page.mouse.up()
+ await wait(700)
+ return true
+ }
+
+ const cat = await catalog()
+ check(
+ 'resumen examen: el catálogo tiene un cable señuelo',
+ !!cat.decoy,
+ cat.error ?? 'sin señuelo en el catálogo',
+ )
+ if (!cat.decoy) {
+ await page.close()
+ return
+ }
+
+ // 1) A los conectores se llega pulsando «Saltar fase», como cualquiera.
+ check('resumen examen: se puede saltar de fase', await clickButton(page, 'Saltar fase'))
+ await wait(1500)
+ check('resumen examen: se puede saltar otra vez', await clickButton(page, 'Saltar fase'))
+ await wait(3500)
+ const stage = await page.$eval('.badge--stage', (el) => el.textContent.trim()).catch(() => '')
+ check('resumen examen: en la fase de conectores', stage === '3. Conectores', stage)
+
+ // 1) Se lee dónde cae un puerto con un cable NORMAL seleccionado (las zonas
+ // iluminadas solo existen en práctica y solo para huecos que encajan), y se
+ // suelta al aire: en la chapa los puertos están tan juntos que soltar «lejos»
+ // puede colocar algo, y la partida se reinicia justo después.
+ const names = await labels()
+ const iDecoy = names.findIndex((n) => n === cat.decoy?.name)
+ const iNormal = names.findIndex((n) => n !== cat.decoy?.name)
+ check(
+ 'resumen examen: hay un cable normal y uno señuelo',
+ iDecoy >= 0 && iNormal >= 0 && iDecoy !== iNormal,
+ `señuelo en ${iDecoy}, normal en ${iNormal}`,
+ )
+ if (iDecoy < 0 || iNormal < 0) {
+ await page.close()
+ return
+ }
+ const item = (await page.$$('.connbar__item'))[iNormal]
+ const box = await item.boundingBox()
+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
+ await page.mouse.down()
+ await wait(250)
+ await page.mouse.move(box.x + box.width / 2 + 40, box.y + box.height / 2 - 40)
+ await wait(400)
+ const port = await page
+ .$eval('.mount-center', (el) => {
+ const r = el.getBoundingClientRect()
+ return { x: r.x + r.width / 2, y: r.y + r.height / 2 }
+ })
+ .catch(() => null)
+ await page.mouse.move(1200, 120)
+ await wait(250)
+ await page.mouse.up()
+ await wait(600)
+ check('resumen examen: el puerto está en pantalla', !!port)
+ if (!port) {
+ await page.close()
+ return
+ }
+
+ // 2) Partida nueva en EXAMEN desde el menú. Al empezar otra partida los puertos
+ // y la cámara de la fase no cambian, así que las coordenadas de arriba
+ // siguen valiendo. Se llega a los conectores rindiendo las dos fases
+ // anteriores: eso deja además piezas sin colocar, que es la mitad del resumen.
+ check('resumen examen: se puede volver al menú', await clickButton(page, 'Menú'))
+ await wait(1000)
+ const examCards = await page
+ .waitForSelector('button.mode-card', { timeout: 60000 })
+ .then(() => page.$$('button.mode-card'))
+ .catch(() => [])
+ check('resumen examen: el menú ofrece los dos modos', examCards.length >= 2, `${examCards.length}`)
+ if (examCards.length < 2) {
+ await page.close()
+ return
+ }
+ await examCards[1].click() // examen
+ await page.waitForSelector('.badge--exam', { timeout: 60000 })
+ await wait(1200)
+ check('resumen examen: se puede rendir una fase', await clickButton(page, 'Rendirse'))
+ await wait(1300)
+ check('resumen examen: se puede rendir otra fase', await clickButton(page, 'Rendirse'))
+ await wait(3500)
+ const examStage = await page.$eval('.badge--stage', (el) => el.textContent.trim()).catch(() => '')
+ check('resumen examen: el examen llega a los conectores', examStage === '3. Conectores', examStage)
+
+ // 3) Un fallo de verdad: el señuelo sobre un puerto real.
+ const dropped = await dragTo(iDecoy, port)
+ await wait(500)
+ check('resumen examen: el fallo llegó a soltarse', dropped)
+
+ // 4) Rendirse en las dos fases que quedan para terminar.
+ check('resumen examen: se puede rendir los conectores', await clickButton(page, 'Rendirse'))
+ await wait(1300)
+ check('resumen examen: se puede rendir la última fase', await clickButton(page, 'Rendirse'))
+ await wait(1300)
+
+ // Lo que ve el alumnado: el título de la tarjeta y sus contadores.
+ const title = await page
+ .$eval('.overlay .card h1', (el) => el.textContent.trim())
+ .catch(() => '')
+ check('resumen examen: la partida terminó', title === 'Examen terminado', `«${title}»`)
+ const boxes = await page.$$eval('.score-box', (els) =>
+ Object.fromEntries(
+ els.map((el) => [
+ (el.querySelector('.lbl')?.textContent ?? '').trim(),
+ Number(((el.querySelector('.val')?.textContent ?? '').replace(/\D/g, '')) || 0),
+ ]),
+ ),
+ )
+ check('resumen examen: el fallo se contó', boxes.Fallos >= 1, JSON.stringify(boxes))
+ check('resumen examen: hay piezas sin colocar', boxes['Sin colocar'] >= 1, JSON.stringify(boxes))
+
+ const missRows = await rowsOf('.finish-review--misses .finish-review__list li')
+ const unplacedRows = await rowsOf('.finish-review--unplaced .finish-review__list li')
+ check('resumen examen: el resumen final está en pantalla', !!(await page.$('.finish-review')))
+ check(
+ 'resumen examen: hay una fila por fallo',
+ missRows.length === boxes.Fallos && missRows.length >= 1,
+ `${missRows.length} filas / ${boxes.Fallos} fallos`,
+ )
+ check(
+ 'resumen examen: la fila nombra la pieza que se arrastró',
+ !!cat.decoy && (missRows[0] ?? '').includes(`«${cat.decoy.name}»`),
+ `«${missRows[0] ?? ''}»`,
+ )
+ check(
+ 'resumen examen: la fila dice qué iba ahí',
+ /→ ahí va «.+»/.test(missRows[0] ?? ''),
+ `«${missRows[0] ?? ''}»`,
+ )
+ const dragged = (missRows[0] ?? '').match(/Pusiste «(.+?)»/)?.[1] ?? ''
+ const correct = (missRows[0] ?? '').match(/→ ahí va «(.+?)»/)?.[1] ?? ''
+ check(
+ 'resumen examen: lo que iba ahí no es lo que se arrastró',
+ !!dragged && !!correct && dragged !== correct,
+ `«${dragged}» → «${correct}»`,
+ )
+ check(
+ 'resumen examen: el sitio nombrado es un puerto de la fase',
+ cat.portLabels.some((label) => (missRows[0] ?? '').includes(`«${label}»`)),
+ `«${missRows[0] ?? ''}»`,
+ )
+ check(
+ 'resumen examen: hay una fila por pieza sin colocar',
+ unplacedRows.length === boxes['Sin colocar'],
+ `${unplacedRows.length} filas / ${boxes['Sin colocar']} sin colocar`,
+ )
+ // El cable que sobra no encaja en ningún puerto, así que solo puede quedarse
+ // sin colocar en la identificación: allí TODAS las piezas se colocan en su
+ // cartel y por eso cuentan, incluidos los señuelos (es la regla de
+ // `STAGE_STEPS`, no un descuido).
+ const decoyRows = unplacedRows.filter((row) => row.includes(cat.decoy.name))
+ check(
+ 'resumen examen: el cable que sobra solo falta en la identificación',
+ decoyRows.length >= 1 && decoyRows.every((row) => row.includes('identificación')),
+ decoyRows.slice(0, 4).join(' | ') || 'no aparece',
+ )
+ check(
+ 'resumen examen: cada pieza sin colocar dice en qué fase faltaba',
+ unplacedRows.length > 0 &&
+ unplacedRows.every((row) =>
+ /\((identificación|sobre la placa|conectores|periféricos)\)/.test(row),
+ ),
+ unplacedRows.slice(0, 3).join(' | '),
+ )
+
+ check('resumen examen: sin errores de página', errors.length === 0, errors.join(' | '))
+ await page.close()
+}
+
+/* ---------- 15. La carga del modelo pesado se avisa con una barra ---------- */
+async function loadingBar() {
+ // El `.glb` de la placa pesa 18,8 MB (el mayor, `motherboard-01`), así que en
+ // el aula no llega de golpe. Hasta ahora lo único que se veía era el
+ // placeholder procedural, que aparecía y luego se sustituía sin explicar nada.
+ //
+ // La red se estrangula DESPUÉS de cargar la app (si no, cargar el propio
+ // programa tardaría minutos) para que la carga del modelo dure lo suficiente
+ // como para mirarla en vez de adivinarla.
+ const { page, errors } = await newPage(browser, `${BASE_URL}/?board=motherboard-01`)
+ const client = await page.createCDPSession()
+ await client.send('Network.enable')
+
+ // Control negativo: en el menú no se carga ningún modelo, así que no hay barra.
+ check('carga: en el menú no hay barra de carga', !(await page.$('.loadingbar')))
+
+ const menuCards = await page
+ .waitForSelector('button.mode-card', { timeout: 60000 })
+ .then(() => page.$$('button.mode-card'))
+ .catch(() => [])
+ check('carga: menú de modos visible', menuCards.length >= 2, `${menuCards.length}`)
+ if (menuCards.length < 2) {
+ await page.close()
+ return
+ }
+
+ await client.send('Network.emulateNetworkConditions', {
+ offline: false,
+ latency: 40,
+ downloadThroughput: 1500 * 1024,
+ uploadThroughput: 300 * 1024,
+ })
+
+ await menuCards[0].click() // práctica
+ await page.waitForSelector('.topbar', { timeout: 60000 })
+ // La fase de la placa es la que monta el modelo grande (no hay preloads). Se
+ // salta con el botón, no con el store: así el aviso no depende de qué
+ // instancia del módulo tenga la app bajo HMR.
+ check('carga: se puede saltar a la fase de la placa', await clickButton(page, 'Saltar fase'))
+
+ const reading = () =>
+ page
+ .$eval('.loadingbar', (bar) => {
+ const fill = bar.querySelector('.loadingbar__fill')
+ return {
+ label: bar.querySelector('.loadingbar__label')?.textContent?.trim() ?? '',
+ pct: bar.querySelector('.loadingbar__pct')?.textContent?.trim() ?? '',
+ ancho: fill ? fill.style.width : '',
+ pointerEvents: getComputedStyle(bar).pointerEvents,
+ }
+ })
+ .catch(() => null)
+
+ let bar = null
+ for (let t = 0; t < 80 && !bar; t++) {
+ await wait(250)
+ bar = await reading()
+ }
+ check('carga: la barra aparece mientras llega el modelo', !!bar, JSON.stringify(bar))
+ if (bar) {
+ check('carga: la barra dice qué está cargando', bar.label.startsWith('Cargando'), `«${bar.label}»`)
+ check('carga: la barra dice un porcentaje', /^\d+ %$/.test(bar.pct), `«${bar.pct}»`)
+ check(
+ 'carga: la barra se rellena con ese porcentaje',
+ /^\d+(\.\d+)?%$/.test(bar.ancho),
+ `ancho «${bar.ancho}» con ${bar.pct}`,
+ )
+ check(
+ 'carga: la barra no bloquea el juego',
+ bar.pointerEvents === 'none',
+ `pointer-events: ${bar.pointerEvents}`,
+ )
+ }
+
+ // Sin estrangulamiento la carga termina, y la barra se va: no se queda pegada.
+ await client.send('Network.emulateNetworkConditions', {
+ offline: false,
+ latency: 0,
+ downloadThroughput: -1,
+ uploadThroughput: -1,
+ })
+ let gone = false
+ for (let t = 0; t < 120 && !gone; t++) {
+ await wait(250)
+ gone = !(await page.$('.loadingbar'))
+ }
+ check('carga: al terminar, la barra desaparece', gone)
+ check('carga: sin errores de página', errors.length === 0, errors.join(' | '))
+ await page.close()
+}
+
+/* ---------- 16. El cartel de fase explica qué hacer al empezarla ---------- */
+async function stageBanner() {
+ // En práctica, al empezar cada fase (también la primera) sale el cartel con lo
+ // que hay que hacer; en examen no sale nunca: no da explicaciones.
+ const { page, errors } = await newPage(browser, `${BASE_URL}/?board=motherboard-01`)
+ const menuCards = await page
+ .waitForSelector('button.mode-card', { timeout: 60000 })
+ .then(() => page.$$('button.mode-card'))
+ .catch(() => [])
+ check('cartel de fase: menú de modos visible', menuCards.length >= 2, `${menuCards.length}`)
+ if (menuCards.length < 2) {
+ await page.close()
+ return
+ }
+ const lee = () =>
+ page
+ .$eval('.stage-banner', (el) => ({
+ kicker: (el.querySelector('.stage-banner__kicker')?.textContent ?? '').trim(),
+ titulo: (el.querySelector('h2')?.textContent ?? '').trim(),
+ texto: (el.querySelector('p')?.textContent ?? '').trim(),
+ }))
+ .catch(() => null)
+
+ await menuCards[0].click() // práctica
+ await page.waitForSelector('.topbar', { timeout: 60000 })
+ await wait(700)
+ const esperado = await page.evaluate(async () => {
+ const m = await import('/src/data/stages.ts')
+ return m.STAGES.map((s) => ({ id: s.id, titulo: s.title, texto: s.hint }))
+ })
+ const primero = await lee()
+ check(
+ 'cartel de fase: al empezar la partida explica la primera fase',
+ !!primero && primero.kicker === 'Fase 1 de 4' && primero.titulo === `1. ${esperado[0].titulo}`,
+ JSON.stringify(primero),
+ )
+ check(
+ 'cartel de fase: el texto es el de esa fase',
+ !!primero && primero.texto === esperado[0].texto,
+ `«${primero?.texto ?? ''}»`,
+ )
+
+ let ido = false
+ for (let t = 0; t < 24 && !ido; t++) {
+ await wait(250)
+ ido = !(await page.$('.stage-banner'))
+ }
+ check('cartel de fase: se quita solo', ido)
+
+ check('cartel de fase: se puede saltar de fase', await clickButton(page, 'Saltar fase'))
+ await wait(800)
+ const segunda = await lee()
+ check(
+ 'cartel de fase: al saltar explica la fase nueva (y no dice «completada»)',
+ !!segunda && segunda.kicker === 'Fase 2 de 4' && segunda.titulo === `2. ${esperado[1].titulo}`,
+ JSON.stringify(segunda),
+ )
+ check('cartel de fase: sin errores de página', errors.length === 0, errors.join(' | '))
+ await page.close()
+
+ // En examen no aparece nunca.
+ const examen = await newPage(browser, `${BASE_URL}/?board=motherboard-01`)
+ const cartas = await examen.page
+ .waitForSelector('button.mode-card', { timeout: 60000 })
+ .then(() => examen.page.$$('button.mode-card'))
+ .catch(() => [])
+ check('cartel de fase: el examen arranca', cartas.length >= 2, `${cartas.length} tarjetas`)
+ if (cartas.length >= 2) {
+ await cartas[1].click()
+ await examen.page.waitForSelector('.badge--exam', { timeout: 60000 })
+ await wait(1500)
+ check('cartel de fase: en examen no aparece', !(await examen.page.$('.stage-banner')))
+ }
+ check('cartel de fase: examen sin errores', examen.errors.length === 0, examen.errors.join(' | '))
+ await examen.page.close()
+}
+
+/* ---------- 17. La pieza seleccionada se ve ampliada en la fase 1 ---------- */
+async function componentZoom() {
+ // En la fase 1 están todas las piezas sobre la mesa y los conectores se ven muy
+ // pequeños: al seleccionar una, sale una ventanita con la pieza ampliada (el
+ // PNG del conector si existe, si no una vista 3D de cerca). En examen el panel
+ // también sale —la forma ayuda— pero SIN el nombre: nombrarla es una pista.
+ const { page, errors } = await newPage(browser, `${BASE_URL}/?board=motherboard-01`)
+ const menuCards = await page
+ .waitForSelector('button.mode-card', { timeout: 60000 })
+ .then(() => page.$$('button.mode-card'))
+ .catch(() => [])
+ check('zoom: menú de modos visible', menuCards.length >= 2, `${menuCards.length}`)
+ if (menuCards.length < 2) {
+ await page.close()
+ return
+ }
+ const lee = () =>
+ page
+ .$eval('.zoom', (el) => ({
+ img: !!el.querySelector('.zoom__img'),
+ canvas: !!el.querySelector('canvas'),
+ nombre: (el.querySelector('.zoom__name')?.textContent ?? '').trim(),
+ pointerEvents: getComputedStyle(el).pointerEvents,
+ }))
+ .catch(() => null)
+ const elegir = async (texto) => {
+ for (const item of await page.$$('button.comp-item')) {
+ const name = ((await item.evaluate((el) => el.querySelector('.name')?.textContent)) ?? '').trim()
+ if (!name.includes(texto)) continue
+ await item.click()
+ await wait(700)
+ return name
+ }
+ return null
+ }
+
+ await menuCards[0].click() // práctica
+ await page.waitForSelector('.topbar', { timeout: 60000 })
+ await openSidePanel(page)
+ await wait(400)
+ check('zoom: sin pieza seleccionada no hay panel', !(await page.$('.zoom')))
+
+ const conPng = await elegir('Cable PS/2')
+ const z1 = await lee()
+ check('zoom: al seleccionar un conector sale el panel', !!z1, JSON.stringify(z1) ?? 'sin panel')
+ check('zoom: el conector se enseña con su imagen nítida', !!z1?.img, JSON.stringify(z1))
+ check(
+ 'zoom: en práctica el panel dice el nombre',
+ !!conPng && z1?.nombre === conPng,
+ `«${z1?.nombre ?? ''}» frente a «${conPng ?? ''}»`,
+ )
+ check('zoom: el panel no bloquea los clics', z1?.pointerEvents === 'none', `pointer-events: ${z1?.pointerEvents}`)
+
+ await elegir('CPU')
+ const z2 = await lee()
+ check(
+ 'zoom: una pieza sin imagen se enseña en 3D',
+ !!z2?.canvas && z2?.img === false,
+ JSON.stringify(z2),
+ )
+
+ check('zoom: se puede saltar de fase', await clickButton(page, 'Saltar fase'))
+ await wait(1200)
+ await elegir('CPU')
+ check('zoom: fuera de la fase 1 no sale el panel', !(await page.$('.zoom')))
+ check('zoom: sin errores de página', errors.length === 0, errors.join(' | '))
+ await page.close()
+
+ // En examen: se coge una pieza de la mesa (no hay lista) y el panel sale sin
+ // nombre. La posición de la pieza en pantalla se calcula con la cámara de la
+ // fase, sin tocar el store.
+ const examen = await newPage(browser, `${BASE_URL}/?board=motherboard-01`)
+ const cartas = await examen.page
+ .waitForSelector('button.mode-card', { timeout: 60000 })
+ .then(() => examen.page.$$('button.mode-card'))
+ .catch(() => [])
+ check('zoom: el examen arranca', cartas.length >= 2, `${cartas.length} tarjetas`)
+ if (cartas.length < 2) {
+ await examen.page.close()
+ return
+ }
+ await cartas[1].click()
+ await examen.page.waitForSelector('.badge--exam', { timeout: 60000 })
+ await wait(1500)
+ check('zoom: en examen no hay panel sin coger nada', !(await examen.page.$('.zoom')))
+
+ const punto = await examen.page.evaluate(async () => {
+ const c = await import('/src/data/components.ts')
+ const st = await import('/src/data/stages.ts')
+ const stage = st.STAGES[0]
+ const def = [...c.COMPONENTS_BY_STAGE.identify].sort((a, b) => b.size - a.size)[0]
+ const [px, py, pz] = stage.camera.position
+ const [tx, ty, tz] = stage.camera.target
+ const n = Math.hypot(tx - px, ty - py, tz - pz)
+ const f = [(tx - px) / n, (ty - py) / n, (tz - pz) / n]
+ const r = [-f[2], 0, f[0]]
+ const nr = Math.hypot(...r)
+ r[0] /= nr
+ r[2] /= nr
+ const u = [r[1] * f[2] - r[2] * f[1], r[2] * f[0] - r[0] * f[2], r[0] * f[1] - r[1] * f[0]]
+ const d = [
+ def.identifyPos[0] - px,
+ // OJO: la pieza dibujada no mide `def.size`, sino como mucho 0,75
+ // (`trayScaleFor` la encoge). Apuntar a `def.size / 2` cae muy por encima.
+ stage.tray.surfaceY + Math.min(def.size, 0.75) / 2 - py,
+ def.identifyPos[1] - pz,
+ ]
+ const xv = d[0] * r[0] + d[1] * r[1] + d[2] * r[2]
+ const yv = d[0] * u[0] + d[1] * u[1] + d[2] * u[2]
+ const zv = d[0] * f[0] + d[1] * f[1] + d[2] * f[2]
+ const tan = Math.tan(((45 * Math.PI) / 180) / 2)
+ const W = window.innerWidth
+ const H = window.innerHeight
+ return {
+ id: def.id,
+ size: def.size,
+ x: ((xv / (zv * tan * (W / H))) + 1) / 2 * W,
+ y: (1 - yv / (zv * tan)) / 2 * H,
+ }
+ })
+ let cogida = false
+ for (const [dx, dy] of [
+ [0, 0],
+ [0, -10],
+ [0, 10],
+ [-10, 0],
+ [10, 0],
+ [-14, -14],
+ [14, 14],
+ [0, -20],
+ [0, 20],
+ ]) {
+ await examen.page.mouse.click(punto.x + dx, punto.y + dy)
+ await wait(500)
+ if (await examen.page.$('.zoom')) {
+ cogida = true
+ break
+ }
+ }
+ check('zoom: en examen sale el panel al coger la pieza', cogida, JSON.stringify(punto))
+ if (cogida) {
+ const z3 = await examen.page.$eval('.zoom', (el) => ({
+ img: !!el.querySelector('.zoom__img'),
+ canvas: !!el.querySelector('canvas'),
+ tieneNombre: !!el.querySelector('.zoom__name'),
+ texto: (el.textContent ?? '').trim(),
+ }))
+ check(
+ 'zoom: en examen el panel NO dice el nombre de la pieza',
+ z3.tieneNombre === false && z3.texto === '',
+ JSON.stringify(z3),
+ )
+ check('zoom: en examen la pieza se enseña igual (3D o imagen)', !!z3.canvas || !!z3.img, JSON.stringify(z3))
+ }
+ check('zoom: examen sin errores de página', examen.errors.length === 0, examen.errors.join(' | '))
+ await examen.page.close()
+}
+
+/**
+ * Secciones de la pasada, en orden. `ONLY=examSummary,loadingBar` acota la
+ * ejecución a las nombradas (para falsar un cambio sin pagar la pasada entera).
+ */
+const SECTIONS = [
+ ['fullGame', fullGame],
+ ['examGiveUpGuard', examGiveUpGuard],
+ ['skipStageGuard', skipStageGuard],
+ ['unknownBoardGuard', unknownBoardGuard],
+ ['rotationsGuard', rotationsGuard],
+ ['unknownBoardNoCalibration', unknownBoardNoCalibration],
+ ['dragGhostVisible', dragGhostVisible],
+ ['sidePanels', sidePanels],
+ ['examNoHelp', examNoHelp],
+ ['traySeparation', traySeparation],
+ ['examDrag', examDrag],
+ ['examSummary', examSummary],
+ ['touchDrag', touchDrag],
+ ['loadingBar', loadingBar],
+ ['stageBanner', stageBanner],
+ ['componentZoom', componentZoom],
+]
+
+const ONLY = process.env.ONLY
+ ? process.env.ONLY.split(',').map((name) => name.trim())
+ : null
+
+// Un nombre que no existe —una errata o una mayúscula cambiada— dejaría la
+// pasada en vacío y aun así diría «TODO OK»: se para y se dicen cuáles hay (lo
+// señaló la revisión nativa: R4-001).
+const unknown = ONLY
+ ? ONLY.filter((name) => !SECTIONS.some(([section]) => section === name))
+ : []
+if (unknown.length > 0) {
+ console.error(
+ `ONLY: no existe la sección «${unknown.join(', ')}». Hay: ${SECTIONS.map(([n]) => n).join(', ')}`,
+ )
+ process.exit(1)
+}
+
+for (const [name, run] of SECTIONS) {
+ if (ONLY && !ONLY.includes(name)) continue
+ await run()
+}
await browser.close()
console.log(failures === 0 ? '\nTODO OK' : `\n${failures} comprobaciones fallidas`)
From c0af6190c22d2675912f871b387c75cc0736d3e4 Mon Sep 17 00:00:00 2001
From: jmoredev
Date: Tue, 13 Oct 2026 19:17:21 +0200
Subject: [PATCH 2/6] fix: el zoom de una pieza la mira desde tres cuartos y la
encuadra entera
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
La ventanita de la fase 1 miraba siempre de frente, así que la CPU y el SSD
salían de canto, el disipador por su parte de abajo y los altavoces de espaldas
(las que son PNG, como los cables, salían bien).
Ahora mide la caja envolvente real de la pieza y coloca la cámara mirando a su
centro desde tres cuartos y desde arriba, a la distancia que la deja entera. El
margen de esa distancia cubre que con perspectiva el extremo cercano de una pieza
larga se agranda: con menos margen la RAM se salía por los dos lados.
---
src/ui/ComponentZoom.tsx | 57 +++++++++++++++++++++++++++++++++++-----
1 file changed, 51 insertions(+), 6 deletions(-)
diff --git a/src/ui/ComponentZoom.tsx b/src/ui/ComponentZoom.tsx
index 37fb59b..fcdfafb 100644
--- a/src/ui/ComponentZoom.tsx
+++ b/src/ui/ComponentZoom.tsx
@@ -1,10 +1,53 @@
-import { Suspense } from 'react'
-import { Canvas } from '@react-three/fiber'
+import { Suspense, useRef, type ReactNode } from 'react'
+import { Canvas, useFrame, useThree } from '@react-three/fiber'
+import * as THREE from 'three'
import { connectorImageUrl } from '../data/assets'
import { COMPONENT_BY_ID } from '../data/components'
import { ComponentVisual } from '../three/ComponentModel'
import type { GameMode } from '../types'
+/** Dirección desde la que se mira la pieza: tres cuartos desde arriba. */
+const MIRADA = new THREE.Vector3(0.45, 0.72, 0.85).normalize()
+
+/**
+ * Encuadra la pieza: mide su caja envolvente real y coloca la cámara mirando a su
+ * centro desde tres cuartos, a la distancia que la deja entera.
+ *
+ * Hace falta porque las piezas no comparten forma ni orientación: la CPU y el SSD
+ * van tumbados (de frente solo se ve el canto), el disipador es bajito (de frente
+ * se ve su parte de abajo) y los altavoces miran hacia atrás. Y la distancia se
+ * mide en vez de estimarse porque la RAM es larga y estrecha: con perspectiva su
+ * extremo cercano se agranda, así que el margen tiene que cubrir el radio ENTERO
+ * visto desde el lado próximo (de ahí el 1,6).
+ *
+ * Se hace una sola vez, en el primer fotograma en que la pieza ya tiene geometría
+ * (con `Suspense` los primeros fotogramas están vacíos), y con `key` por pieza
+ * para que cada una se encuadre al seleccionarla.
+ */
+function Encuadre({ children }: { children: ReactNode }) {
+ const camera = useThree((s) => s.camera)
+ const grupo = useRef(null)
+ const listo = useRef(false)
+
+ useFrame(() => {
+ if (listo.current) return
+ const g = grupo.current
+ if (!g) return
+ const caja = new THREE.Box3().setFromObject(g)
+ if (caja.isEmpty()) return
+ const centro = caja.getCenter(new THREE.Vector3())
+ const radio = caja.getSize(new THREE.Vector3()).length() / 2 || 0.2
+ const fov = 'fov' in camera ? camera.fov : 30
+ const distancia = (radio / Math.tan((fov * Math.PI) / 360)) * 1.6
+ camera.position.copy(centro).addScaledVector(MIRADA, distancia)
+ camera.lookAt(centro)
+ camera.updateProjectionMatrix()
+ listo.current = true
+ })
+
+ return {children}
+}
+
/**
* Ventanita con la pieza seleccionada ampliada. En la fase 1 están todas sobre la
* mesa y algunas —los conectores— se ven muy pequeñas: aquí se ven enteras.
@@ -31,14 +74,16 @@ export function ComponentZoom({ id, mode }: { id: string; mode: GameMode }) {
{image ? (
) : (
-
+
-
-
-
+
+
+
+
+
)}
From c1c42435637bcc3139532c644d19f0c82d1d8f75 Mon Sep 17 00:00:00 2001
From: jmoredev
Date: Tue, 13 Oct 2026 21:14:44 +0200
Subject: [PATCH 3/6] fix: la ventanita del zoom no mantiene su bucle y la
barra se anuncia como progreso
Dos hallazgos de la revision nativa del rango comprometido (dfd2838..HEAD):
- R4-001 (resilience): el mini lienzo de la pieza usaba el frameloop por defecto
(always), asi que mantenia un bucle de render WebGL propio mientras hubiera una
pieza seleccionada, aunque la escena sea estatica tras el encuadre. En los
portatiles modestos que busca el proyecto eso es carga de GPU sostenida; pasa a
demand (el encuadre sigue teniendo su fotograma al montar y al llegar el modelo).
- R4-002 (resilience): la barra de carga anunciaba cada cambio de porcentaje por
aria-live, decenas de anuncios mientras baja un modelo de 18,8 MB. Pasa a
role=progressbar con aria-valuenow/aria-valuemin/aria-valuemax y aria-label, y
la seccion 15 del arnes lo fija con una comprobacion nueva.
El CRITICAL R3-001 de esa revision (selectedId anulable en ExamMiss) se disputa:
quitando la guarda if (!selectedId) return de endDrag, tsc falla exactamente con
ese error, y con la guarda sale en 0.
---
src/ui/ComponentZoom.tsx | 7 ++++++-
src/ui/LoadingBar.tsx | 12 +++++++++++-
tests/run.mjs | 13 +++++++++++++
3 files changed, 30 insertions(+), 2 deletions(-)
diff --git a/src/ui/ComponentZoom.tsx b/src/ui/ComponentZoom.tsx
index fcdfafb..cb1f851 100644
--- a/src/ui/ComponentZoom.tsx
+++ b/src/ui/ComponentZoom.tsx
@@ -74,7 +74,12 @@ export function ComponentZoom({ id, mode }: { id: string; mode: GameMode }) {
{image ? (
) : (
-
+
+ {/* `demand`: la escena del zoom es estática tras el primer encuadre, así
+ que no hay que mantener un bucle de render propio (y menos en los
+ portátiles modestos que busca el proyecto). r3f renderiza al montar
+ y cuando llega el modelo, que es cuando `Encuadre` necesita su
+ fotograma. */}
diff --git a/src/ui/LoadingBar.tsx b/src/ui/LoadingBar.tsx
index 01c0052..33cfd5b 100644
--- a/src/ui/LoadingBar.tsx
+++ b/src/ui/LoadingBar.tsx
@@ -23,7 +23,17 @@ export function LoadingBar() {
: 'Cargando los modelos 3D…'
return (
-
+ // `progressbar` con su valor en vez de `status`+`aria-live`: el porcentaje
+ // cambia decenas de veces mientras baja un modelo grande y un lector de
+ // pantalla encolaría un anuncio por cada cambio.
+
{label}
{pct} %
diff --git a/tests/run.mjs b/tests/run.mjs
index 9114183..c55bcd1 100644
--- a/tests/run.mjs
+++ b/tests/run.mjs
@@ -2039,6 +2039,9 @@ async function loadingBar() {
pct: bar.querySelector('.loadingbar__pct')?.textContent?.trim() ?? '',
ancho: fill ? fill.style.width : '',
pointerEvents: getComputedStyle(bar).pointerEvents,
+ role: bar.getAttribute('role') ?? '
',
+ valor: bar.getAttribute('aria-valuenow') ?? '',
+ nombre: bar.getAttribute('aria-label') ?? '',
}
})
.catch(() => null)
@@ -2062,6 +2065,16 @@ async function loadingBar() {
bar.pointerEvents === 'none',
`pointer-events: ${bar.pointerEvents}`,
)
+ // Se anuncia como progreso con su valor, no como `status` con `aria-live`:
+ // el porcentaje cambia decenas de veces y un lector de pantalla encolaría un
+ // anuncio por cada cambio (hallazgo R4-002 de la revisión nativa).
+ check(
+ 'carga: la barra se anuncia como progreso con su valor',
+ bar.role === 'progressbar' &&
+ bar.valor === bar.pct.replace(' %', '') &&
+ bar.nombre.startsWith('Cargando'),
+ `role=${bar.role}, aria-valuenow=${bar.valor}, aria-label=«${bar.nombre}» con ${bar.pct}`,
+ )
}
// Sin estrangulamiento la carga termina, y la barra se va: no se queda pegada.
From 014da1ff7ee06d420b5a2812afe9a54ba857ab8d Mon Sep 17 00:00:00 2001
From: jmoredev
Date: Tue, 13 Oct 2026 21:47:28 +0200
Subject: [PATCH 4/6] fix: la ventanita del zoom no pierde su contexto y el
arnes cubre cache y fase completada
Cuatro hallazgos advisory de la revision nativa del candidato c1c4243:
- R4-001 (resilience): alternar entre un conector con PNG y una pieza 3D
desmontaba y montaba el Canvas, y cada montaje crea un contexto WebGL nuevo:
alternando en la fase 1 se puede agotar el limite del navegador y la ventanita
deja de pintar. Ahora el lienzo se queda montado y la imagen se superpone.
- R4-002 (resilience): la imagen del conector gana onError y cae a la vista 3D
si el PNG falla, en vez de dejar el icono roto.
- R3-001 (reliability): la seccion 15 desactiva la cache del navegador; con la
cache, secciones anteriores ya habian cargado el mismo modelo y la barra podia
no llegar a verse.
- R3-002 (reliability): la seccion 16 completa la fase 2 colocando sus piezas y
comprueba el kicker "Fase completada", que antes solo se probaba al saltar.
---
src/index.css | 13 +++++++++++++
src/ui/ComponentZoom.tsx | 36 ++++++++++++++++++++++++------------
tests/run.mjs | 40 +++++++++++++++++++++++++++++++++++++++-
3 files changed, 76 insertions(+), 13 deletions(-)
diff --git a/src/index.css b/src/index.css
index 8426358..c234048 100644
--- a/src/index.css
+++ b/src/index.css
@@ -770,13 +770,26 @@ button {
}
.zoom__view {
+ position: relative;
height: 132px;
display: flex;
align-items: center;
justify-content: center;
}
+/* El lienzo se queda montado aunque se enseñe el PNG: montarlo y desmontarlo al
+ cambiar de pieza creaba un contexto WebGL por ciclo (ver ComponentZoom). */
+.zoom__canvas {
+ position: absolute;
+ inset: 0;
+}
+
+.zoom__canvas--tapado {
+ visibility: hidden;
+}
+
.zoom__img {
+ position: relative;
max-width: 92%;
max-height: 92%;
object-fit: contain;
diff --git a/src/ui/ComponentZoom.tsx b/src/ui/ComponentZoom.tsx
index cb1f851..46430cf 100644
--- a/src/ui/ComponentZoom.tsx
+++ b/src/ui/ComponentZoom.tsx
@@ -1,4 +1,4 @@
-import { Suspense, useRef, type ReactNode } from 'react'
+import { Suspense, useRef, useState, type ReactNode } from 'react'
import { Canvas, useFrame, useThree } from '@react-three/fiber'
import * as THREE from 'three'
import { connectorImageUrl } from '../data/assets'
@@ -52,17 +52,24 @@ function Encuadre({ children }: { children: ReactNode }) {
* Ventanita con la pieza seleccionada ampliada. En la fase 1 están todas sobre la
* mesa y algunas —los conectores— se ven muy pequeñas: aquí se ven enteras.
*
- * Para los conectores con imagen propia se usa el PNG (más nítido que el dibujo
- * 3D); para el resto, una vista 3D de cerca con la misma pieza que se arrastra.
+ * Para los conectores con imagen propia se enseña el PNG (más nítido que el
+ * dibujo 3D) y para el resto la vista 3D de cerca. **El lienzo se queda montado
+ * siempre**, aunque se enseñe el PNG: montarlo y desmontarlo al cambiar de pieza
+ * creaba un contexto WebGL nuevo en cada ciclo y, alternando en la fase 1, se
+ * puede agotar el límite del navegador.
*
* En examen no se escribe el nombre: decir qué pieza es también es una pista
* (misma regla que en la barra de estado).
*/
export function ComponentZoom({ id, mode }: { id: string; mode: GameMode }) {
const def = COMPONENT_BY_ID[id]
+ /** Pieza cuyo PNG no cargó: se cae a la vista 3D en vez de dejar la imagen rota. */
+ const [pngRoto, setPngRoto] = useState(null)
+
if (!def) return null
- const image = def.category === 'conector' ? connectorImageUrl(def.kind) : undefined
+ const conImagen = def.category === 'conector' ? connectorImageUrl(def.kind) : undefined
+ const imagen = pngRoto === def.id ? undefined : conImagen
// La pieza se dibuja a un tamaño parecido en la ventanita sea cual sea su
// `size` (los modelos se autoajustan a `def.size`; los conectores procedurales
// son más pequeños y se agrandan con este factor).
@@ -71,15 +78,11 @@ export function ComponentZoom({ id, mode }: { id: string; mode: GameMode }) {
return (
- {image ? (
-
- ) : (
+
- {/* `demand`: la escena del zoom es estática tras el primer encuadre, así
- que no hay que mantener un bucle de render propio (y menos en los
- portátiles modestos que busca el proyecto). r3f renderiza al montar
- y cuando llega el modelo, que es cuando `Encuadre` necesita su
- fotograma. */}
+ {/* `demand`: la escena es estática tras el primer encuadre, así que no
+ hay que mantener un bucle de render propio (y menos en los
+ portátiles modestos que busca el proyecto). */}
@@ -91,6 +94,15 @@ export function ComponentZoom({ id, mode }: { id: string; mode: GameMode }) {
+
+ {imagen && (
+
setPngRoto(def.id)}
+ />
)}
{mode === 'practice' &&
{def.name} }
diff --git a/tests/run.mjs b/tests/run.mjs
index c55bcd1..052fb97 100644
--- a/tests/run.mjs
+++ b/tests/run.mjs
@@ -2000,6 +2000,10 @@ async function loadingBar() {
// programa tardaría minutos) para que la carga del modelo dure lo suficiente
// como para mirarla en vez de adivinarla.
const { page, errors } = await newPage(browser, `${BASE_URL}/?board=motherboard-01`)
+ // Sin caché: las secciones anteriores ya cargaron este mismo modelo, así que la
+ // barra podría no aparecer (o durar demasiado poco para verla) y la prueba
+ // saldría frágil (hallazgo R3-001 de la revisión nativa).
+ await page.setCacheEnabled(false)
const client = await page.createCDPSession()
await client.send('Network.enable')
@@ -2122,7 +2126,7 @@ async function stageBanner() {
await wait(700)
const esperado = await page.evaluate(async () => {
const m = await import('/src/data/stages.ts')
- return m.STAGES.map((s) => ({ id: s.id, titulo: s.title, texto: s.hint }))
+ return m.STAGES.map((s) => ({ id: s.id, titulo: s.title, texto: s.hint, corto: s.short }))
})
const primero = await lee()
check(
@@ -2151,6 +2155,40 @@ async function stageBanner() {
!!segunda && segunda.kicker === 'Fase 2 de 4' && segunda.titulo === `2. ${esperado[1].titulo}`,
JSON.stringify(segunda),
)
+
+ // Y al COMPLETAR una fase el cartel lo dice: se completa la fase 2 colocando sus
+ // piezas con el mismo gesto que usa el resto del arnés (lista + zona iluminada).
+ // Hay que abrir la lista a mano (el panel arranca plegado) y usar los índices de
+ // SU grupo: la lista agrupa las piezas por fase y solo deja seleccionar las de la
+ // fase actual, así que las de la fase 2 empiezan en `IDENTIFY` (las 18 de la
+ // identificación van primero).
+ await openSidePanel(page)
+ let intentos = 0
+ let ultimoOk = null
+ for (let n = 0; n < 10; n++) {
+ const fase = await page.$eval('.badge--stage', (el) => el.textContent.trim()).catch(() => '')
+ if (fase !== esperado[1].corto) break
+ ultimoOk = await place(page, IDENTIFY + n)
+ intentos++
+ await wait(900)
+ if (!ultimoOk) break
+ }
+ const faseFinal = await page
+ .$eval('.badge--stage', (el) => el.textContent.trim())
+ .catch(() => '
')
+ // El cartel dura 3,6 s y el momento exacto en que aparece depende del último
+ // encaje, así que se sondea en vez de leerlo en un instante.
+ let completada = null
+ for (let t = 0; t < 24 && !completada; t++) {
+ const visto = await lee()
+ if (visto && visto.kicker === 'Fase completada') completada = visto
+ else await wait(250)
+ }
+ check(
+ 'cartel de fase: al completar una fase lo dice',
+ !!completada && completada.titulo === `3. ${esperado[2].titulo}`,
+ `${JSON.stringify(completada)} · fase final «${faseFinal}» · ${intentos} colocaciones, último ok=${ultimoOk}`,
+ )
check('cartel de fase: sin errores de página', errors.length === 0, errors.join(' | '))
await page.close()
From 87366877b3140091d913ae7a08abb44fced02580 Mon Sep 17 00:00:00 2001
From: jmoredev
Date: Fri, 16 Oct 2026 09:04:24 +0200
Subject: [PATCH 5/6] fix: la ventanita de la fase 1 se monta una sola vez y el
arnes cubre el ciclo
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
El panel del zoom se montaba condicionado a la pieza seleccionada, asi que se
desmontaba en cada colocacion de la fase 1 (18 por partida) y cada montaje
creaba un contexto WebGL nuevo; el que el navegador se lleva por agotamiento es
el mas antiguo, o sea el de la escena principal. Ahora el panel (y su lienzo)
viven toda la fase 1 y, sin seleccion, se ocultan con `zoom--oculto` (asi el
lienzo conserva su tamano y su contexto); fuera de la fase 1 se desmontan,
porque alli el lienzo no hace falta. Hallazgo R4-001 de la revision nativa.
Arnes:
- `componentZoom` (seccion 17) da una vuelta entera al ciclo de la fase 1
-seleccionar, colocar (que deselecciona) y volver a seleccionar- y marca el
nodo del lienzo: falsado, con el montaje condicional fallan las cinco
comprobaciones de ese ciclo. La primera version del check (marcador entre dos
selecciones seguidas) no cazaba el defecto; se corrigio al falsarlo.
- Seccion nueva 18 (`examIdentifyHint`): en la fase 1 de examen se coge una
pieza de la mesa y se lleva sobre su cartel, con el control de que el puntero
llego a estar encima; falsado, sin la guardia de la barra de estado el aviso
vuelve a decir «Suelta aqui: ».
- El calculo de pantalla de la fase 1 sale de la seccion 17 a un ayudante
(`pantallaFase1`) que ahora comparten las dos.
AGENTS.md: decision 59 (el panel se monta una vez por fase, no por pieza) y la
56 apunta tambien a la 18.
Verificado: `tsc -b` en 0, `npm run lint` sin avisos y las dos secciones en
verde tras restaurar el arreglo y la guardia.
---
AGENTS.md | 3 +-
src/App.tsx | 9 +-
src/index.css | 7 +
src/ui/ComponentZoom.tsx | 44 ++++---
tests/run.mjs | 268 +++++++++++++++++++++++++++++++++------
5 files changed, 266 insertions(+), 65 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index cedbbd6..e8edf31 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -190,9 +190,10 @@ Reglas de layout:
| 53 | **El resumen del examen dice qué se falló y qué quedó sin colocar** | Una nota sobre 100 no enseña nada: `FinishModal` solo mostraba contadores. Ahora el store anota cada fallo con la pieza arrastrada y el hueco señalado (`misses`) y las piezas que quedaron sin poner al rendirse (`unplaced`). El recuento y la lista de lo que falta salen de la **misma** función (`stageComponents()` en `src/data/components.ts`, de la que también se deriva `STAGE_STEPS`), así que no pueden discrepar, y el fallo se anota bajo la **misma** condición que lo cuenta, así que `errors` es siempre el número de filas. Cada fila dice la pieza, el hueco y cuál iba ahí, que es el momento didáctico; las piezas sin colocar llevan además **la fase en la que faltaban**, porque la misma pieza puede faltar dos veces en una partida (identificarla y montarla): sin eso la lista salía repetida y sin explicación, y con la fase la clave de cada fila es única. El resumen es solo del examen (en práctica no hay fallos que anotar) y no cambia la puntuación. |
| 54 | **Los modelos que tardan se avisan con una barra, sin bloquear** | El `.glb` de la placa pesa hasta 18,8 MB (medido: `motherboard-01` son 652.238 vértices y sus texturas, 0,08 MB, así que el peso es geometría ya cuantizada, no texturas) y con placa aleatoria por carga una de cada tres partidas lo descarga al empezar la fase 2. Antes lo único que se veía era el placeholder procedural, que aparecía y luego se sustituía sin explicar nada. La barra usa `useProgress` de drei sobre el gestor de carga de three, va arriba bajo la barra superior y con `pointer-events: none`, así que no bloquea el juego: se puede seguir mirando la escena. Desaparece al terminar porque `FileLoader` llama a `itemEnd()` en su `.finally()`, también cuando el modelo falla. |
| 55 | **La mesa de la identificación se reparte en 6 columnas × 3 filas** (filas a 2,1 u) | En la fase 1 están las 18 piezas a la vez y la cámara mira en oblicuo, así que dos piezas con la misma X se dibujan **una detrás de otra**: con la rejilla anterior (4 filas a 1,25) la separación en profundidad dentro de una «misma columna visual» era 0,9 u (~71 px) y en 200 barajados había **1.628 pares** por debajo del suelo. Con 3 filas a 2,1 u son 1,42 u (~110 px) y 0 pares. El ancho se queda en ±4 y la fila de delante en 4,4 **a propósito, medido con la proyección de la cámara**: 9 columnas hasta ±5,2 sacaban piezas de la mesa visible (x≈118 px) y las metían detrás de la ficha (x≈1190-1282), y una fila en 5,8 las cortaba en el borde inferior. El arnés lo fija con el check de «misma columna visual» (§11), que hoy falla si se vuelve a las 4 filas. |
-| 56 | **En examen la barra de estado no da el nombre de la pieza ni el del hueco** | El examen consiste en reconocer las piezas: «CPU seleccionada» o «Suelta aquí: Zócalo de la CPU» era la respuesta (decisión 51). En práctica se sigue diciendo todo; en examen quedan solo las instrucciones («Pieza seleccionada. Arrástrala hasta su sitio.» y «Lleva la pieza hasta el hueco correcto.»). Cubierto en §12. |
+| 56 | **En examen la barra de estado no da el nombre de la pieza ni el del hueco** | El examen consiste en reconocer las piezas: «CPU seleccionada» o «Suelta aquí: Zócalo de la CPU» era la respuesta (decisión 51). En práctica se sigue diciendo todo; en examen quedan solo las instrucciones («Pieza seleccionada. Arrástrala hasta su sitio.» y «Lleva la pieza hasta el hueco correcto.»). Cubierto en §12 (fase de conectores) y en §18 (fase 1, donde el «hueco» es el cartel con el nombre del componente: allí el mismo aviso decía la respuesta entera). |
| 57 | **En práctica, cada fase empieza con su cartel** | Antes el cartel salía solo al completar una fase, así que quien arrancaba la partida o saltaba de fase no veía qué había que hacer. Ahora sale al **empezar** cada fase en práctica (también la primera, y también al reiniciar), se quita solo a los 3,6 s, y el estado guarda `lastAdvance` para poder decir «Fase completada» solo cuando se llegó completando y no cuando se saltó. En examen no se monta: el examen no da explicaciones. Cubierto en §16. |
| 58 | **La pieza seleccionada se ve ampliada en una ventanita** (fase 1) | En la fase 1 están todas las piezas sobre la mesa y los conectores se ven muy pequeños. Al seleccionar una, una ventanita la enseña entera: el **PNG del conector** cuando existe (más nítido que el dibujo 3D: `ps2`, `usb`, `lan`, `hdmi`, `displayport`, `dvi`, `vga`) y, si no, una **vista 3D de cerca** con la misma `ComponentVisual` (drei no trae `View` en esta versión, así que es un mini lienzo). Va al centro-abajo —la captura enseñó que a la izquierda tapaba la lista de fases—, con `pointer-events: none` para no estorbar el arrastre, solo en la fase 1 y **sin el nombre en examen** (misma regla que la 56). Cubierto en §17. |
+| 59 | **La ventanita del zoom se monta una vez por fase, no por pieza** | Montarla condicionada a la pieza seleccionada la desmontaba **en cada colocación** de la fase 1 (18 por partida) y **cada montaje creaba un contexto WebGL nuevo**; el que el navegador se lleva por agotamiento es el más antiguo, o sea el de la **escena principal**, y la vista 3D dejaría de pintar. Ahora el panel (y su lienzo) viven toda la fase 1 y sin selección se **ocultan** con `zoom--oculto` (así el lienzo conserva su tamaño y su contexto); fuera de la fase 1 se desmontan, porque ahí no hacen falta. Hallazgo `R4-001` de la revisión nativa; cubierto en §17, donde el ciclo **seleccionar → colocar (que deselecciona) → seleccionar** se comprueba de verdad y un marcador sobre el nodo del lienzo detecta cualquier remontaje (falsado: con el montaje condicional fallan las cinco comprobaciones de ese ciclo). |
## 7. Pipeline de assets 3D
diff --git a/src/App.tsx b/src/App.tsx
index c22f420..4d6d2bd 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -117,10 +117,11 @@ export default function App() {
{cableBar && }
{/* En la fase 1 las piezas se ven pequeñas: la seleccionada aparece
- ampliada (con su nombre solo en práctica). */}
- {stage.id === 'identify' && selectedId && (
-
- )}
+ ampliada (con su nombre solo en práctica). El panel se monta
+ durante toda la fase y se oculta sin selección: montarlo y
+ desmontarlo con cada colocación creaba un contexto WebGL por
+ ciclo (R4-001). */}
+ {stage.id === 'identify' && }
{phase === 'building' && }
>
)}
diff --git a/src/index.css b/src/index.css
index c234048..2fe4b5a 100644
--- a/src/index.css
+++ b/src/index.css
@@ -769,6 +769,13 @@ button {
z-index: 11;
}
+/* Sin pieza seleccionada el panel se queda montado pero invisible: así su lienzo
+ (y su contexto WebGL) sobrevive a las 18 colocaciones de la fase 1 y no se
+ crea uno nuevo en cada una (R4-001). */
+.zoom--oculto {
+ visibility: hidden;
+}
+
.zoom__view {
position: relative;
height: 132px;
diff --git a/src/ui/ComponentZoom.tsx b/src/ui/ComponentZoom.tsx
index 46430cf..e685f3e 100644
--- a/src/ui/ComponentZoom.tsx
+++ b/src/ui/ComponentZoom.tsx
@@ -53,30 +53,30 @@ function Encuadre({ children }: { children: ReactNode }) {
* mesa y algunas —los conectores— se ven muy pequeñas: aquí se ven enteras.
*
* Para los conectores con imagen propia se enseña el PNG (más nítido que el
- * dibujo 3D) y para el resto la vista 3D de cerca. **El lienzo se queda montado
- * siempre**, aunque se enseñe el PNG: montarlo y desmontarlo al cambiar de pieza
- * creaba un contexto WebGL nuevo en cada ciclo y, alternando en la fase 1, se
- * puede agotar el límite del navegador.
+ * dibujo 3D) y para el resto la vista 3D de cerca. **El panel entero se queda
+ * montado durante toda la fase 1**, también sin pieza seleccionada: montarlo y
+ * desmontarlo con cada colocación (18 por partida) creaba un contexto WebGL por
+ * ciclo, y el que el navegador se lleva por agotamiento es el más antiguo, el de
+ * la escena principal. Sin selección se oculta (`zoom--oculto`), que mantiene el
+ * lienzo y su tamaño.
*
* En examen no se escribe el nombre: decir qué pieza es también es una pista
* (misma regla que en la barra de estado).
*/
-export function ComponentZoom({ id, mode }: { id: string; mode: GameMode }) {
- const def = COMPONENT_BY_ID[id]
+export function ComponentZoom({ id, mode }: { id: string | null; mode: GameMode }) {
+ const def = id ? COMPONENT_BY_ID[id] : undefined
/** Pieza cuyo PNG no cargó: se cae a la vista 3D en vez de dejar la imagen rota. */
const [pngRoto, setPngRoto] = useState(null)
- if (!def) return null
-
- const conImagen = def.category === 'conector' ? connectorImageUrl(def.kind) : undefined
- const imagen = pngRoto === def.id ? undefined : conImagen
+ const conImagen = def?.category === 'conector' ? connectorImageUrl(def.kind) : undefined
+ const imagen = def && pngRoto !== def.id ? conImagen : undefined
// La pieza se dibuja a un tamaño parecido en la ventanita sea cual sea su
// `size` (los modelos se autoajustan a `def.size`; los conectores procedurales
// son más pequeños y se agrandan con este factor).
- const scale = 1.15 / Math.max(0.12, def.size)
+ const scale = def ? 1.15 / Math.max(0.12, def.size) : 1
return (
-
+
@@ -86,16 +86,18 @@ export function ComponentZoom({ id, mode }: { id: string; mode: GameMode }) {
-
-
-
-
-
-
-
+ {def && (
+
+
+
+
+
+
+
+ )}
- {imagen && (
+ {def && imagen && (
)}
- {mode === 'practice' &&
{def.name} }
+ {def && mode === 'practice' &&
{def.name} }
)
}
diff --git a/tests/run.mjs b/tests/run.mjs
index 052fb97..0663989 100644
--- a/tests/run.mjs
+++ b/tests/run.mjs
@@ -116,6 +116,17 @@ async function isVisible(page, selector) {
.catch(() => false)
}
+/**
+ * ¿Se ve el panel de la pieza? Desde R4-001 el panel está montado durante toda la
+ * fase 1 y se oculta sin selección, así que «está en el DOM» ya no distingue nada:
+ * lo que dice que hay una pieza cogida es que se vea.
+ */
+async function zoomPanelVisible(page) {
+ return page
+ .$eval('.zoom', (el) => getComputedStyle(el).visibility !== 'hidden')
+ .catch(() => false)
+}
+
/**
* Pulsa el primer botón de la barra superior cuyo texto empiece por `text`.
* Preferir esto al import dinámico del store: el botón es lo que pulsa una
@@ -2210,6 +2221,61 @@ async function stageBanner() {
}
/* ---------- 17. La pieza seleccionada se ve ampliada en la fase 1 ---------- */
+/**
+ * Punto de pantalla de la fase 1 donde se dibuja algo del mundo: `'pieza'` es la
+ * pieza de la mesa del alumno y `'cartel'` es el cartel que le toca (los huecos de
+ * la fase 1, `MOUNTS_BY_STAGE.identify`).
+ *
+ * La cámara de la fase 1 es fija y la declara `stages.ts`, pero el arnés no tiene
+ * la instancia del lienzo: la proyección se hace a mano con su posición, su
+ * objetivo y su fov. El `y` de la pieza no es `def.size / 2`: la pieza dibujada no
+ * mide lo que su `size` (la encoge `trayScaleFor` hasta 0,75), y apuntar a la mitad
+ * de su `size` cae muy por encima de ella.
+ */
+async function pantallaFase1(page, id, que) {
+ return page.evaluate(
+ async ({ id, que }) => {
+ const c = await import('/src/data/components.ts')
+ const st = await import('/src/data/stages.ts')
+ const stage = st.STAGES[0]
+ const def = c.COMPONENT_BY_ID[id]
+ let mundo
+ if (que === 'cartel') {
+ const mount = c.MOUNTS_BY_STAGE.identify.find((m) => m.accepts.includes(def.kind))
+ if (!mount) return null
+ mundo = mount.position
+ } else {
+ mundo = [
+ def.identifyPos[0],
+ stage.tray.surfaceY + Math.min(def.size, 0.75) / 2,
+ def.identifyPos[1],
+ ]
+ }
+ const [px, py, pz] = stage.camera.position
+ const [tx, ty, tz] = stage.camera.target
+ const n = Math.hypot(tx - px, ty - py, tz - pz)
+ const f = [(tx - px) / n, (ty - py) / n, (tz - pz) / n]
+ const r = [-f[2], 0, f[0]]
+ const nr = Math.hypot(...r)
+ r[0] /= nr
+ r[2] /= nr
+ const u = [r[1] * f[2] - r[2] * f[1], r[2] * f[0] - r[0] * f[2], r[0] * f[1] - r[1] * f[0]]
+ const d = [mundo[0] - px, mundo[1] - py, mundo[2] - pz]
+ const xv = d[0] * r[0] + d[1] * r[1] + d[2] * r[2]
+ const yv = d[0] * u[0] + d[1] * u[1] + d[2] * u[2]
+ const zv = d[0] * f[0] + d[1] * f[1] + d[2] * f[2]
+ const tan = Math.tan((45 * Math.PI) / 180 / 2)
+ const W = window.innerWidth
+ const H = window.innerHeight
+ return {
+ x: (xv / (zv * tan * (W / H)) + 1) / 2 * W,
+ y: (1 - yv / (zv * tan)) / 2 * H,
+ }
+ },
+ { id, que },
+ )
+}
+
async function componentZoom() {
// En la fase 1 están todas las piezas sobre la mesa y los conectores se ven muy
// pequeños: al seleccionar una, sale una ventanita con la pieza ampliada (el
@@ -2232,6 +2298,7 @@ async function componentZoom() {
canvas: !!el.querySelector('canvas'),
nombre: (el.querySelector('.zoom__name')?.textContent ?? '').trim(),
pointerEvents: getComputedStyle(el).pointerEvents,
+ visible: getComputedStyle(el).visibility !== 'hidden',
}))
.catch(() => null)
const elegir = async (texto) => {
@@ -2249,7 +2316,15 @@ async function componentZoom() {
await page.waitForSelector('.topbar', { timeout: 60000 })
await openSidePanel(page)
await wait(400)
- check('zoom: sin pieza seleccionada no hay panel', !(await page.$('.zoom')))
+ // El panel se queda montado durante toda la fase 1 y se oculta sin selección:
+ // montarlo y desmontarlo con cada colocación (18 por partida) creaba un contexto
+ // WebGL por ciclo (R4-001).
+ const z0 = await lee()
+ check(
+ 'zoom: sin pieza seleccionada el panel está montado y oculto',
+ !!z0?.canvas && z0.visible === false,
+ JSON.stringify(z0) ?? 'sin panel',
+ )
const conPng = await elegir('Cable PS/2')
const z1 = await lee()
@@ -2262,6 +2337,30 @@ async function componentZoom() {
)
check('zoom: el panel no bloquea los clics', z1?.pointerEvents === 'none', `pointer-events: ${z1?.pointerEvents}`)
+ // Se marca el nodo del lienzo y se da una vuelta entera al ciclo de la fase 1:
+ // seleccionar → colocar (que deselecciona) → seleccionar, que es lo que pasa 18
+ // veces por partida. Si el panel se remontara en cada vuelta, el marcador
+ // desaparecería (sería otro nodo, o no habría ninguno).
+ const marcado = await page
+ .$eval('.zoom canvas', (el) => {
+ el.dataset.marca = 'panel-unico'
+ return true
+ })
+ .catch(() => false)
+
+ const colocado = await place(page, 0)
+ check('zoom: se coloca una pieza en la fase 1', colocado)
+ await wait(400)
+ const trasColocar = await lee()
+ const marcaTrasColocar = await page
+ .$eval('.zoom canvas', (el) => el.dataset.marca === 'panel-unico')
+ .catch(() => false)
+ check(
+ 'zoom: tras colocar una pieza el panel sigue montado y oculto',
+ !!trasColocar?.canvas && trasColocar.visible === false && marcaTrasColocar,
+ `${JSON.stringify(trasColocar)} · marcador: ${marcaTrasColocar}`,
+ )
+
await elegir('CPU')
const z2 = await lee()
check(
@@ -2269,11 +2368,23 @@ async function componentZoom() {
!!z2?.canvas && z2?.img === false,
JSON.stringify(z2),
)
+ const z2marca = await page
+ .$eval('.zoom canvas', (el) => el.dataset.marca === 'panel-unico')
+ .catch(() => false)
+ check(
+ 'zoom: el lienzo del panel es el mismo tras un ciclo de colocación',
+ marcado && z2marca,
+ `marcado: ${marcado}, marcador presente: ${z2marca}`,
+ )
+ const lienzos = await page.$$eval('canvas', (els) => els.length)
+ check('zoom: solo hay dos lienzos (escena y panel) tras el ciclo', lienzos === 2, `${lienzos}`)
check('zoom: se puede saltar de fase', await clickButton(page, 'Saltar fase'))
await wait(1200)
await elegir('CPU')
- check('zoom: fuera de la fase 1 no sale el panel', !(await page.$('.zoom')))
+ // Fuera de la fase 1 el panel se desmonta del todo: el lienzo solo hace falta
+ // mientras están las piezas sobre la mesa.
+ check('zoom: fuera de la fase 1 el panel se desmonta', !(await page.$('.zoom')))
check('zoom: sin errores de página', errors.length === 0, errors.join(' | '))
await page.close()
@@ -2293,42 +2404,15 @@ async function componentZoom() {
await cartas[1].click()
await examen.page.waitForSelector('.badge--exam', { timeout: 60000 })
await wait(1500)
- check('zoom: en examen no hay panel sin coger nada', !(await examen.page.$('.zoom')))
-
- const punto = await examen.page.evaluate(async () => {
- const c = await import('/src/data/components.ts')
- const st = await import('/src/data/stages.ts')
- const stage = st.STAGES[0]
- const def = [...c.COMPONENTS_BY_STAGE.identify].sort((a, b) => b.size - a.size)[0]
- const [px, py, pz] = stage.camera.position
- const [tx, ty, tz] = stage.camera.target
- const n = Math.hypot(tx - px, ty - py, tz - pz)
- const f = [(tx - px) / n, (ty - py) / n, (tz - pz) / n]
- const r = [-f[2], 0, f[0]]
- const nr = Math.hypot(...r)
- r[0] /= nr
- r[2] /= nr
- const u = [r[1] * f[2] - r[2] * f[1], r[2] * f[0] - r[0] * f[2], r[0] * f[1] - r[1] * f[0]]
- const d = [
- def.identifyPos[0] - px,
- // OJO: la pieza dibujada no mide `def.size`, sino como mucho 0,75
- // (`trayScaleFor` la encoge). Apuntar a `def.size / 2` cae muy por encima.
- stage.tray.surfaceY + Math.min(def.size, 0.75) / 2 - py,
- def.identifyPos[1] - pz,
- ]
- const xv = d[0] * r[0] + d[1] * r[1] + d[2] * r[2]
- const yv = d[0] * u[0] + d[1] * u[1] + d[2] * u[2]
- const zv = d[0] * f[0] + d[1] * f[1] + d[2] * f[2]
- const tan = Math.tan(((45 * Math.PI) / 180) / 2)
- const W = window.innerWidth
- const H = window.innerHeight
- return {
- id: def.id,
- size: def.size,
- x: ((xv / (zv * tan * (W / H))) + 1) / 2 * W,
- y: (1 - yv / (zv * tan)) / 2 * H,
- }
- })
+ check('zoom: en examen tampoco se ve el panel sin coger nada', !(await zoomPanelVisible(examen.page)))
+
+ const mayor = await examen.page.evaluate(() =>
+ import('/src/data/components.ts').then((m) => {
+ const def = [...m.COMPONENTS_BY_STAGE.identify].sort((a, b) => b.size - a.size)[0]
+ return { id: def.id, size: def.size }
+ }),
+ )
+ const punto = await pantallaFase1(examen.page, mayor.id, 'pieza')
let cogida = false
for (const [dx, dy] of [
[0, 0],
@@ -2343,7 +2427,7 @@ async function componentZoom() {
]) {
await examen.page.mouse.click(punto.x + dx, punto.y + dy)
await wait(500)
- if (await examen.page.$('.zoom')) {
+ if (await zoomPanelVisible(examen.page)) {
cogida = true
break
}
@@ -2354,6 +2438,7 @@ async function componentZoom() {
img: !!el.querySelector('.zoom__img'),
canvas: !!el.querySelector('canvas'),
tieneNombre: !!el.querySelector('.zoom__name'),
+ visible: getComputedStyle(el).visibility !== 'hidden',
texto: (el.textContent ?? '').trim(),
}))
check(
@@ -2362,11 +2447,115 @@ async function componentZoom() {
JSON.stringify(z3),
)
check('zoom: en examen la pieza se enseña igual (3D o imagen)', !!z3.canvas || !!z3.img, JSON.stringify(z3))
+ check('zoom: al coger la pieza el panel se ve', z3.visible === true, JSON.stringify(z3))
}
check('zoom: examen sin errores de página', examen.errors.length === 0, examen.errors.join(' | '))
await examen.page.close()
}
+/**
+ * La fase 1 de examen tiene su propio aviso en la barra de estado: al arrastrar una
+ * pieza sobre su cartel, nombrar el cartel correcto es la respuesta entera.
+ *
+ * Se ejerce el gesto de verdad (coger la pieza de la mesa y llevarla al cartel)
+ * porque el aviso solo sale con el puntero encima del hueco, y se comprueba además
+ * que el puntero sí llegó a estar encima: sin eso, la negativa pasaría sin haber
+ * probado nada.
+ */
+async function examIdentifyHint() {
+ const { page, errors } = await newPage(browser, `${BASE_URL}/?board=motherboard-01`)
+ const cartas = await page
+ .waitForSelector('button.mode-card', { timeout: 60000 })
+ .then(() => page.$$('button.mode-card'))
+ .catch(() => [])
+ check('examen fase 1: menú de modos visible', cartas.length >= 2, `${cartas.length}`)
+ if (cartas.length < 2) {
+ await page.close()
+ return
+ }
+ await cartas[1].click() // examen
+ await page.waitForSelector('.badge--exam', { timeout: 60000 })
+ await wait(1500)
+
+ const estado = (clave) =>
+ page.evaluate(
+ (clave) =>
+ import('/src/store/useGameStore.ts').then(
+ (m) => m.useGameStore.getState()[clave],
+ ),
+ clave,
+ )
+ const barra = () =>
+ page.$eval('.statusbar span:last-child', (el) => el.textContent.trim()).catch(() => '')
+
+ const mayor = await page.evaluate(() =>
+ import('/src/data/components.ts').then((m) => {
+ const def = [...m.COMPONENTS_BY_STAGE.identify].sort((a, b) => b.size - a.size)[0]
+ return { id: def.id, name: def.name }
+ }),
+ )
+ const pieza = await pantallaFase1(page, mayor.id, 'pieza')
+ const cartel = await pantallaFase1(page, mayor.id, 'cartel')
+ check(
+ 'examen fase 1: la pieza y su cartel se localizan en pantalla',
+ !!pieza && !!cartel,
+ `${mayor.name}: ${JSON.stringify(pieza)} · cartel ${JSON.stringify(cartel)}`,
+ )
+ if (!pieza || !cartel) {
+ await page.close()
+ return
+ }
+
+ // Coger la pieza falla si el clic cae en el hueco entre geometrías, así que se
+ // prueban los mismos desplazamientos que usa la sección del zoom.
+ let visto = null
+ for (const [dx, dy] of [
+ [0, 0],
+ [0, -10],
+ [0, 10],
+ [-10, 0],
+ [10, 0],
+ [-14, -14],
+ [14, 14],
+ ]) {
+ await page.mouse.move(pieza.x + dx, pieza.y + dy)
+ await page.mouse.down()
+ await wait(300)
+ if (!(await estado('dragging'))) {
+ await page.mouse.up()
+ await wait(200)
+ continue
+ }
+ await page.mouse.move(pieza.x + dx, pieza.y + dy - 40)
+ await wait(250)
+ await page.mouse.move(cartel.x, cartel.y)
+ await wait(500)
+ visto = { hover: await estado('hoverMountId'), barra: await barra() }
+ await page.mouse.up()
+ await wait(600)
+ break
+ }
+
+ check('examen fase 1: la pieza se llega a coger y arrastrar', !!visto, `${mayor.name}`)
+ check(
+ 'examen fase 1: el puntero llegó a estar sobre el cartel correcto',
+ !!visto?.hover,
+ `${visto?.hover ?? 'sin hueco'} · «${visto?.barra ?? 'sin barra'}»`,
+ )
+ check(
+ 'examen fase 1: la barra de estado no nombra el cartel',
+ !!visto?.hover && !/Suelta aquí/.test(visto.barra),
+ `«${visto?.barra ?? ''}»`,
+ )
+ check(
+ 'examen fase 1: la barra dice la instrucción de siempre',
+ visto?.barra === 'Lleva la pieza hasta el hueco correcto.',
+ `«${visto?.barra ?? ''}»`,
+ )
+ check('examen fase 1: sin errores de página', errors.length === 0, errors.join(' | '))
+ await page.close()
+}
+
/**
* Secciones de la pasada, en orden. `ONLY=examSummary,loadingBar` acota la
* ejecución a las nombradas (para falsar un cambio sin pagar la pasada entera).
@@ -2388,6 +2577,7 @@ const SECTIONS = [
['loadingBar', loadingBar],
['stageBanner', stageBanner],
['componentZoom', componentZoom],
+ ['examIdentifyHint', examIdentifyHint],
]
const ONLY = process.env.ONLY
From 6d5ace46619e89b3813ada995949716d8946de54 Mon Sep 17 00:00:00 2001
From: jmoredev
Date: Fri, 16 Oct 2026 09:52:54 +0200
Subject: [PATCH 6/6] fix: los carteles de la fase 1 se separan para no contar
el vecino
Cada cartel acepta un solo tipo de pieza, asi que la pieza nunca se colocaba en el
cartel equivocado: el defecto que reporto el alumnado era de conteo. `endDrag`
cuenta fallo cuando NO hay cartel correcto a `snapRadius` (0,6) y SI uno ajeno a
`wrongRadius` (0,6), de modo que esa banda existia mientras dos carteles estuvieran
a menos de 0,6 + 0,6 = 1,2 u. Con las filas a 0,95 u empezaba a 0,35 u del centro,
o sea 0,15 u mas alla del borde visible de la tarjeta (1,0 x 0,4): soltar cerca del
borde contaba el cartel de al lado y restaba puntos (medido en examen: 0 -> 1
fallos y 100 -> 97 puntos).
El reparto pasa a 6 columnas x 3 filas (18 celdas exactas) con las filas a 1,75 u,
asi que las dos regiones ya no se solapan: fallar de largo solo deja la pieza sin
colocar. Medido con la proyeccion de la camara: el peor hueco en pantalla entre dos
tarjetas pasa de 23 px a 31 px y la fila de atras queda a 0,25 u del borde de la
mesa. Cuatro filas de cinco columnas no caben mas separadas (a 1,25 u el margen al
borde es 0,1 u y a 1,3 se sale de la mesa).
Arnes:
- Seccion nueva 19 (`identifyCards`): la regla geometrica (separacion minima >=
snapRadius + wrongRadius, leida de los datos), el hueco en pantalla entre tarjetas
y el efecto reportado en examen -soltar 0,7 u hacia el cartel vecino mas proximo-,
con el control de que sobre su propio cartel si coloca. Falsado: con las filas a
0,95 u fallan los cuatro (separacion 0,95 < 1,20; hueco 23 < 28 px; fallos 0 -> 1
y puntos 100 -> 97).
- Los desplazamientos para coger una pieza de la mesa estaban repetidos en tres
secciones y solo una tenia los de abajo: ahora son la constante `COGER_PIEZA`. Sin
ellos el monitor no se dejaba coger (su centro dibujado queda 0,15 u por debajo
del punto que calcula el arnes: lo encoge `trayScaleFor`) y las comprobaciones
dependientes caian como si fuera un defecto del producto.
AGENTS.md: decision 60 con las mediciones antes y despues.
Verificado: `tsc -b` en 0, `npm run lint` sin avisos y la pasada integra del arnes
en verde (todas las secciones).
---
AGENTS.md | 1 +
src/data/components.ts | 27 ++-
tests/run.mjs | 390 ++++++++++++++++++++++++++++++++++-------
3 files changed, 353 insertions(+), 65 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index e8edf31..229a38e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -194,6 +194,7 @@ Reglas de layout:
| 57 | **En práctica, cada fase empieza con su cartel** | Antes el cartel salía solo al completar una fase, así que quien arrancaba la partida o saltaba de fase no veía qué había que hacer. Ahora sale al **empezar** cada fase en práctica (también la primera, y también al reiniciar), se quita solo a los 3,6 s, y el estado guarda `lastAdvance` para poder decir «Fase completada» solo cuando se llegó completando y no cuando se saltó. En examen no se monta: el examen no da explicaciones. Cubierto en §16. |
| 58 | **La pieza seleccionada se ve ampliada en una ventanita** (fase 1) | En la fase 1 están todas las piezas sobre la mesa y los conectores se ven muy pequeños. Al seleccionar una, una ventanita la enseña entera: el **PNG del conector** cuando existe (más nítido que el dibujo 3D: `ps2`, `usb`, `lan`, `hdmi`, `displayport`, `dvi`, `vga`) y, si no, una **vista 3D de cerca** con la misma `ComponentVisual` (drei no trae `View` en esta versión, así que es un mini lienzo). Va al centro-abajo —la captura enseñó que a la izquierda tapaba la lista de fases—, con `pointer-events: none` para no estorbar el arrastre, solo en la fase 1 y **sin el nombre en examen** (misma regla que la 56). Cubierto en §17. |
| 59 | **La ventanita del zoom se monta una vez por fase, no por pieza** | Montarla condicionada a la pieza seleccionada la desmontaba **en cada colocación** de la fase 1 (18 por partida) y **cada montaje creaba un contexto WebGL nuevo**; el que el navegador se lleva por agotamiento es el más antiguo, o sea el de la **escena principal**, y la vista 3D dejaría de pintar. Ahora el panel (y su lienzo) viven toda la fase 1 y sin selección se **ocultan** con `zoom--oculto` (así el lienzo conserva su tamaño y su contexto); fuera de la fase 1 se desmontan, porque ahí no hacen falta. Hallazgo `R4-001` de la revisión nativa; cubierto en §17, donde el ciclo **seleccionar → colocar (que deselecciona) → seleccionar** se comprueba de verdad y un marcador sobre el nodo del lienzo detecta cualquier remontaje (falsado: con el montaje condicional fallan las cinco comprobaciones de ese ciclo). |
+| 60 | **Los carteles de la fase 1 se reparten en 6 × 3, con las filas a 1,75 u** | Cada cartel acepta **un solo tipo** de pieza, así que la pieza nunca se coloca en el cartel equivocado: el defecto que reportó el alumnado era de **conteo**. `endDrag` cuenta fallo cuando **no** hay cartel correcto a `snapRadius` (0,6) y **sí** uno ajeno a `wrongRadius` (0,6), y con las filas a 0,95 u esa banda empezaba **a 0,35 u del centro**, o sea 0,15 u más allá del borde visible de la tarjeta (1,0 × 0,4): soltar cerca del borde contaba el vecino y **restaba puntos** (medido en examen: 100 → 97, 0 → 1 fallos). Con 1,75 u las dos regiones ya no se solapan (1,75 ≥ 0,6 + 0,6): fallar de largo solo deja la pieza sin colocar, sin coste. El peor hueco en pantalla entre dos tarjetas pasa de **23 px a 31 px**. Cuatro filas de cinco columnas no caben más separadas —a 1,25 u la de atrás queda a 0,1 u del borde de la mesa y a 1,3 se sale—, así que la forma pasa a 6 × 3 (18 celdas exactas, sin huecos). Todo medido con la proyección de la cámara, y el agarre de la pieza en el arnés quedó unificado en `COGER_PIEZA` (el monitor caía 0,15 u por encima del punto calculado y no se dejaba coger). Cubierto en §19, con la regla geométrica, el hueco en pantalla y la suelta real a 0,7 u; falsado: con 0,95 u fallan los cuatro. |
## 7. Pipeline de assets 3D
diff --git a/src/data/components.ts b/src/data/components.ts
index ca4f685..f05ec4a 100644
--- a/src/data/components.ts
+++ b/src/data/components.ts
@@ -654,10 +654,29 @@ export function rotationPreviewDef(target: RotationTarget, rotation: Vec3): Comp
}
}
-/** Rejilla de los carteles de la identificación, más separada que la bandeja. */
-const PLATE_COLS = [-3.6, -1.8, 0, 1.8, 3.6]
-const PLATE_Z0 = -1.05
-const PLATE_DZ = 0.95
+/**
+ * Rejilla de los carteles de la identificación: 6 columnas × 3 filas (18 carteles,
+ * uno por pieza, así que no sobra ninguna celda).
+ *
+ * La separación entre filas no es estética: en la fase 1 la colocación se resuelve
+ * por cercanía al centro del cartel con `snapRadius` 0,6, y el fallo se cuenta
+ * cuando un cartel AJENO está a `wrongRadius` (0,6) del punto donde se suelta
+ * (`endDrag` de `useGameStore`). Con las filas a 0,95 u —lo que había— la banda
+ * que cuenta el vecino empezaba a 0,35 u del centro, o sea **0,15 u más allá del
+ * borde visible de la tarjeta** (1,0 × 0,4): soltar cerca del borde contaba como
+ * fallo del cartel de al lado y restaba puntos. Ahora la fila mide 1,75 u, así que
+ * las dos regiones ya no se solapan (1,75 ≥ 0,6 + 0,6): fallar de largo solo
+ * significa que la pieza no se coloca, y el peor hueco en pantalla entre dos
+ * tarjetas pasa de 23 px a 31 px.
+ *
+ * Todo medido con la proyección de la cámara (`tests/`, sonda de la fase 1): cuatro
+ * filas de 5 columnas no caben más separadas —con 1,25 la de atrás queda a 0,1 u
+ * del borde de la mesa y con 1,3 se sale— y seis columnas dejan 55 px de hueco
+ * horizontal con tres filas de 1,75.
+ */
+const PLATE_COLS = [-3.75, -2.25, -0.75, 0.75, 2.25, 3.75]
+const PLATE_Z0 = -0.95
+const PLATE_DZ = 1.75
function plateSlot(index: number, rowCount: number): [number, number] {
const col = Math.min(Math.floor(index / rowCount), PLATE_COLS.length - 1)
diff --git a/tests/run.mjs b/tests/run.mjs
index 0663989..e2011cc 100644
--- a/tests/run.mjs
+++ b/tests/run.mjs
@@ -101,6 +101,27 @@ function findChrome() {
const wait = (ms) => new Promise((r) => setTimeout(r, ms))
+/**
+ * Desplazamientos que prueba el arnés para coger una pieza de la mesa (fase 1). El
+ * punto que se calcula es el centro de la pieza según su `size`, pero la pieza
+ * dibujada la encoge `trayScaleFor` a 0,75 como mucho y en las anchas (el monitor)
+ * su centro queda bastante por debajo: sin los desplazamientos hacia abajo, coger
+ * el monitor falla y las comprobaciones dependientes caen como si fuera producto.
+ */
+const COGER_PIEZA = [
+ [0, 0],
+ [0, -10],
+ [0, 10],
+ [-10, 0],
+ [10, 0],
+ [0, -20],
+ [0, 20],
+ [-20, 0],
+ [20, 0],
+ [-14, -14],
+ [14, 14],
+]
+
/**
* ¿El elemento existe Y ocupa sitio en pantalla? La presencia en el DOM no dice
* nada: un panel plegado o una tarjeta sin montar siguen existiendo en el HTML.
@@ -2222,60 +2243,74 @@ async function stageBanner() {
/* ---------- 17. La pieza seleccionada se ve ampliada en la fase 1 ---------- */
/**
- * Punto de pantalla de la fase 1 donde se dibuja algo del mundo: `'pieza'` es la
- * pieza de la mesa del alumno y `'cartel'` es el cartel que le toca (los huecos de
- * la fase 1, `MOUNTS_BY_STAGE.identify`).
- *
- * La cámara de la fase 1 es fija y la declara `stages.ts`, pero el arnés no tiene
- * la instancia del lienzo: la proyección se hace a mano con su posición, su
- * objetivo y su fov. El `y` de la pieza no es `def.size / 2`: la pieza dibujada no
- * mide lo que su `size` (la encoge `trayScaleFor` hasta 0,75), y apuntar a la mitad
- * de su `size` cae muy por encima de ella.
+ * Proyecta puntos del mundo de la fase 1 a la pantalla. La cámara de la fase 1 es
+ * fija y la declara `stages.ts`, pero el arnés no tiene la instancia del lienzo: la
+ * proyección se hace a mano con su posición, su objetivo y su fov (45°).
+ */
+async function proyectaFase1(page, puntos) {
+ return page.evaluate(async (puntos) => {
+ const st = await import('/src/data/stages.ts')
+ const stage = st.STAGES[0]
+ const [px, py, pz] = stage.camera.position
+ const [tx, ty, tz] = stage.camera.target
+ const n = Math.hypot(tx - px, ty - py, tz - pz)
+ const f = [(tx - px) / n, (ty - py) / n, (tz - pz) / n]
+ const r = [-f[2], 0, f[0]]
+ const nr = Math.hypot(...r)
+ r[0] /= nr
+ r[2] /= nr
+ const u = [r[1] * f[2] - r[2] * f[1], r[2] * f[0] - r[0] * f[2], r[0] * f[1] - r[1] * f[0]]
+ const tan = Math.tan((45 * Math.PI) / 180 / 2)
+ const W = window.innerWidth
+ const H = window.innerHeight
+ return puntos.map(([x, y, z]) => {
+ const d = [x - px, y - py, z - pz]
+ const xv = d[0] * r[0] + d[1] * r[1] + d[2] * r[2]
+ const yv = d[0] * u[0] + d[1] * u[1] + d[2] * u[2]
+ const zv = d[0] * f[0] + d[1] * f[1] + d[2] * f[2]
+ return { x: (xv / (zv * tan * (W / H)) + 1) / 2 * W, y: (1 - yv / (zv * tan)) / 2 * H }
+ })
+ }, puntos)
+}
+
+/**
+ * Punto del mundo de la fase 1: `'pieza'` es la pieza de la mesa del alumno y
+ * `'cartel'` es el cartel que le toca (los huecos de la fase 1,
+ * `MOUNTS_BY_STAGE.identify`), desplazado `dz` en profundidad si se pide (para
+ * probar sueltas cerca del borde). El `y` de la pieza no es `def.size / 2`: la
+ * pieza dibujada no mide lo que su `size` (la encoge `trayScaleFor` hasta 0,75) y
+ * apuntar a la mitad de su `size` cae muy por encima de ella.
*/
-async function pantallaFase1(page, id, que) {
+async function puntoFase1(page, id, que, dz = 0) {
return page.evaluate(
- async ({ id, que }) => {
+ async ({ id, que, dz }) => {
const c = await import('/src/data/components.ts')
const st = await import('/src/data/stages.ts')
const stage = st.STAGES[0]
const def = c.COMPONENT_BY_ID[id]
- let mundo
if (que === 'cartel') {
const mount = c.MOUNTS_BY_STAGE.identify.find((m) => m.accepts.includes(def.kind))
if (!mount) return null
- mundo = mount.position
- } else {
- mundo = [
- def.identifyPos[0],
- stage.tray.surfaceY + Math.min(def.size, 0.75) / 2,
- def.identifyPos[1],
- ]
- }
- const [px, py, pz] = stage.camera.position
- const [tx, ty, tz] = stage.camera.target
- const n = Math.hypot(tx - px, ty - py, tz - pz)
- const f = [(tx - px) / n, (ty - py) / n, (tz - pz) / n]
- const r = [-f[2], 0, f[0]]
- const nr = Math.hypot(...r)
- r[0] /= nr
- r[2] /= nr
- const u = [r[1] * f[2] - r[2] * f[1], r[2] * f[0] - r[0] * f[2], r[0] * f[1] - r[1] * f[0]]
- const d = [mundo[0] - px, mundo[1] - py, mundo[2] - pz]
- const xv = d[0] * r[0] + d[1] * r[1] + d[2] * r[2]
- const yv = d[0] * u[0] + d[1] * u[1] + d[2] * u[2]
- const zv = d[0] * f[0] + d[1] * f[1] + d[2] * f[2]
- const tan = Math.tan((45 * Math.PI) / 180 / 2)
- const W = window.innerWidth
- const H = window.innerHeight
- return {
- x: (xv / (zv * tan * (W / H)) + 1) / 2 * W,
- y: (1 - yv / (zv * tan)) / 2 * H,
+ return [mount.position[0], mount.position[1], mount.position[2] + dz]
}
+ return [
+ def.identifyPos[0],
+ stage.tray.surfaceY + Math.min(def.size, 0.75) / 2,
+ def.identifyPos[1],
+ ]
},
- { id, que },
+ { id, que, dz },
)
}
+/** Punto de pantalla de una pieza de la mesa o del cartel que le toca. */
+async function pantallaFase1(page, id, que, dz = 0) {
+ const mundo = await puntoFase1(page, id, que, dz)
+ if (!mundo) return null
+ const [punto] = await proyectaFase1(page, [mundo])
+ return punto
+}
+
async function componentZoom() {
// En la fase 1 están todas las piezas sobre la mesa y los conectores se ven muy
// pequeños: al seleccionar una, sale una ventanita con la pieza ampliada (el
@@ -2414,17 +2449,7 @@ async function componentZoom() {
)
const punto = await pantallaFase1(examen.page, mayor.id, 'pieza')
let cogida = false
- for (const [dx, dy] of [
- [0, 0],
- [0, -10],
- [0, 10],
- [-10, 0],
- [10, 0],
- [-14, -14],
- [14, 14],
- [0, -20],
- [0, 20],
- ]) {
+ for (const [dx, dy] of COGER_PIEZA) {
await examen.page.mouse.click(punto.x + dx, punto.y + dy)
await wait(500)
if (await zoomPanelVisible(examen.page)) {
@@ -2507,17 +2532,9 @@ async function examIdentifyHint() {
}
// Coger la pieza falla si el clic cae en el hueco entre geometrías, así que se
- // prueban los mismos desplazamientos que usa la sección del zoom.
+ // prueban los mismos desplazamientos que usa el resto de la fase 1 (`COGER_PIEZA`).
let visto = null
- for (const [dx, dy] of [
- [0, 0],
- [0, -10],
- [0, 10],
- [-10, 0],
- [10, 0],
- [-14, -14],
- [14, 14],
- ]) {
+ for (const [dx, dy] of COGER_PIEZA) {
await page.mouse.move(pieza.x + dx, pieza.y + dy)
await page.mouse.down()
await wait(300)
@@ -2556,6 +2573,256 @@ async function examIdentifyHint() {
await page.close()
}
+/**
+ * Los carteles de la fase 1. Cada cartel acepta un solo tipo de pieza, así que la
+ * pieza NUNCA se coloca en el cartel equivocado: el fallo que reportó el alumnado
+ * es otro. `endDrag` cuenta un fallo cuando no hay cartel correcto a `snapRadius`
+ * y sí uno AJENO a `wrongRadius`, de modo que la «banda que cuenta el vecino»
+ * existe mientras dos carteles estén más cerca que `snapRadius + wrongRadius`
+ * (0,6 + 0,6 = 1,2 u).
+ *
+ * Se comprueban las dos caras: la regla sobre el reparto (geométrica) y el efecto
+ * observable en examen —soltar 0,7 u hacia el cartel vecino más próximo— que es lo
+ * que hacía perder puntos.
+ */
+async function identifyCards() {
+ const { page, errors } = await newPage(browser, `${BASE_URL}/?board=motherboard-01`)
+ const cartas = await page
+ .waitForSelector('button.mode-card', { timeout: 60000 })
+ .then(() => page.$$('button.mode-card'))
+ .catch(() => [])
+ check('carteles: menú de modos visible', cartas.length >= 2, `${cartas.length}`)
+ if (cartas.length < 2) {
+ await page.close()
+ return
+ }
+ await wait(600)
+
+ const reparto = await page.evaluate(async () => {
+ const c = await import('/src/data/components.ts')
+ const st = await import('/src/data/stages.ts')
+ const stage = st.STAGES[0]
+ const mounts = c.MOUNTS_BY_STAGE.identify
+ const [w, d] = mounts[0].size
+ let minPlano = Infinity
+ let minX = Infinity
+ let minZ = Infinity
+ for (let i = 0; i < mounts.length; i++) {
+ for (let j = i + 1; j < mounts.length; j++) {
+ const dx = Math.abs(mounts[i].position[0] - mounts[j].position[0])
+ const dz = Math.abs(mounts[i].position[2] - mounts[j].position[2])
+ minPlano = Math.min(minPlano, Math.hypot(dx, dz))
+ if (dz < 0.01) minX = Math.min(minX, dx)
+ if (dx < 0.01) minZ = Math.min(minZ, dz)
+ }
+ }
+ return {
+ cuantos: mounts.length,
+ piezas: c.COMPONENTS_BY_STAGE.identify.length,
+ snap: Math.max(...mounts.map((m) => m.snapRadius)),
+ wrong: stage.drop.wrongRadius,
+ minPlano,
+ minX,
+ minZ,
+ bordeTrasero: Math.min(...mounts.map((m) => m.position[2])) - d / 2,
+ mesaTrasera: stage.bench.center[1] - stage.bench.depth / 2,
+ esquinas: mounts.map((m) => [
+ [m.position[0] - w / 2, 0.09, m.position[2] - d / 2],
+ [m.position[0] + w / 2, 0.09, m.position[2] - d / 2],
+ [m.position[0] - w / 2, 0.09, m.position[2] + d / 2],
+ [m.position[0] + w / 2, 0.09, m.position[2] + d / 2],
+ ]),
+ }
+ })
+
+ check(
+ 'carteles: hay uno por cada pieza',
+ reparto.cuantos === reparto.piezas,
+ `${reparto.cuantos} carteles para ${reparto.piezas} piezas`,
+ )
+ check(
+ 'carteles: ningún cartel ajeno cabe en la banda de fallo del suyo',
+ reparto.minPlano >= reparto.snap + reparto.wrong,
+ `separación mínima ${reparto.minPlano.toFixed(2)} u (X ${reparto.minX.toFixed(2)}, Z ${reparto.minZ.toFixed(2)}) frente a snapRadius + wrongRadius = ${(reparto.snap + reparto.wrong).toFixed(2)} u`,
+ )
+ check(
+ 'carteles: la fila de atrás cabe en la mesa',
+ reparto.bordeTrasero >= reparto.mesaTrasera,
+ `borde trasero ${reparto.bordeTrasero.toFixed(2)} u, la mesa llega a ${reparto.mesaTrasera.toFixed(2)} u`,
+ )
+
+ // Hueco en pantalla entre los rectángulos de dos tarjetas vecinas: es lo que se
+ // ve («están muy pegadas»). El umbral es el medido con este reparto (31 px); con
+ // las filas a 0,95 u eran 23 px.
+ const proyectadas = await proyectaFase1(page, reparto.esquinas.flat())
+ const cajas = reparto.esquinas.map((_, i) => {
+ const pts = proyectadas.slice(i * 4, i * 4 + 4)
+ const xs = pts.map((p) => p.x)
+ const ys = pts.map((p) => p.y)
+ return { x0: Math.min(...xs), x1: Math.max(...xs), y0: Math.min(...ys), y1: Math.max(...ys) }
+ })
+ let hueco = Infinity
+ for (let i = 0; i < cajas.length; i++) {
+ for (let j = i + 1; j < cajas.length; j++) {
+ const dx = Math.max(0, Math.max(cajas[j].x0 - cajas[i].x1, cajas[i].x0 - cajas[j].x1))
+ const dy = Math.max(0, Math.max(cajas[j].y0 - cajas[i].y1, cajas[i].y0 - cajas[j].y1))
+ hueco = Math.min(hueco, Math.hypot(dx, dy))
+ }
+ }
+ check(
+ 'carteles: dos tarjetas vecinas no se pegan en pantalla',
+ hueco >= 28,
+ `${Math.round(hueco)} px de hueco entre las dos más próximas; hacen falta 28`,
+ )
+ check('carteles: el reparto se mide sin errores de página', errors.length === 0, errors.join(' | '))
+ await page.close()
+
+ // El efecto que reportó el alumnado, en examen y con el ratón: se elige la pieza
+ // cuyo cartel tiene al vecino MÁS próximo (con el reparto anterior son las filas a
+ // 0,95 u) y se suelta 0,7 u hacia él, que es media tarjeta más allá de su borde.
+ const examen = await newPage(browser, `${BASE_URL}/?board=motherboard-01`)
+ const cartasExamen = await examen.page
+ .waitForSelector('button.mode-card', { timeout: 60000 })
+ .then(() => examen.page.$$('button.mode-card'))
+ .catch(() => [])
+ check('carteles: el examen arranca', cartasExamen.length >= 2, `${cartasExamen.length} tarjetas`)
+ if (cartasExamen.length < 2) {
+ await examen.page.close()
+ return
+ }
+ await cartasExamen[1].click()
+ await examen.page.waitForSelector('.badge--exam', { timeout: 60000 })
+ await wait(1500)
+
+ const objetivo = await examen.page.evaluate(async () => {
+ const c = await import('/src/data/components.ts')
+ const mounts = c.MOUNTS_BY_STAGE.identify
+ let mejor = null
+ for (const def of c.COMPONENTS_BY_STAGE.identify) {
+ const m = mounts.find((o) => o.accepts.includes(def.kind))
+ if (!m) continue
+ let best = Infinity
+ let dir = null
+ for (const otro of mounts) {
+ if (otro === m) continue
+ const dx = otro.position[0] - m.position[0]
+ const dz = otro.position[2] - m.position[2]
+ const d = Math.hypot(dx, dz)
+ if (d < best) {
+ best = d
+ dir = [dx / d, dz / d]
+ }
+ }
+ if (!mejor || best < mejor.distancia) {
+ mejor = { id: def.id, name: def.name, distancia: best, dir, cartel: m.position }
+ }
+ }
+ return mejor
+ })
+ const cerca = {
+ x: objetivo.cartel[0] + objetivo.dir[0] * 0.7,
+ z: objetivo.cartel[2] + objetivo.dir[1] * 0.7,
+ }
+ const pieza = await pantallaFase1(examen.page, objetivo.id, 'pieza')
+ const [lejos] = await proyectaFase1(examen.page, [[cerca.x, objetivo.cartel[1], cerca.z]])
+ const [encima] = await proyectaFase1(examen.page, [objetivo.cartel])
+ check(
+ 'carteles: la pieza, su cartel y el punto vecino se localizan en pantalla',
+ !!pieza && !!lejos && !!encima,
+ `${objetivo.name}: pieza ${JSON.stringify(pieza)}, a 0,7 u ${JSON.stringify(lejos)} (vecino a ${objetivo.distancia.toFixed(2)} u)`,
+ )
+ if (!pieza || !lejos || !encima) {
+ await examen.page.close()
+ return
+ }
+
+ const juego = () =>
+ examen.page.evaluate(() =>
+ import('/src/store/useGameStore.ts').then((m) => {
+ const s = m.useGameStore.getState()
+ return {
+ errors: s.errors,
+ colocadas: Object.keys(s.placed).length,
+ filas: s.misses.length,
+ }
+ }),
+ )
+ const puntos = () =>
+ examen.page.$$eval('.topbar .badge', (els) =>
+ els.map((el) => el.textContent ?? '').find((t) => t.startsWith('Puntos:')) ?? '',
+ )
+
+ // Coger la pieza falla si el clic cae en el hueco entre geometrías: mismos
+ // desplazamientos que usan las otras secciones de la fase 1.
+ const arrastra = async (destino) => {
+ for (const [dx, dy] of COGER_PIEZA) {
+ await examen.page.mouse.move(pieza.x + dx, pieza.y + dy)
+ await examen.page.mouse.down()
+ await wait(300)
+ const cogida = await examen.page.evaluate(() =>
+ import('/src/store/useGameStore.ts').then((m) => m.useGameStore.getState().dragging),
+ )
+ if (!cogida) {
+ await examen.page.mouse.up()
+ await wait(200)
+ continue
+ }
+ await examen.page.mouse.move(pieza.x + dx, pieza.y + dy - 40)
+ await wait(250)
+ await examen.page.mouse.move(destino.x, destino.y)
+ await wait(500)
+ await examen.page.mouse.up()
+ await wait(800)
+ return true
+ }
+ return false
+ }
+
+ const antes = await juego()
+ const puntosAntes = await puntos()
+ const soltado = await arrastra(lejos)
+ const despues = await juego()
+ const puntosDespues = await puntos()
+ check(
+ 'carteles: la pieza se coge y se suelta a 0,7 u de su cartel',
+ soltado,
+ `${objetivo.name}`,
+ )
+ check(
+ 'carteles: soltar pasado el borde no cuenta el cartel de al lado',
+ despues.errors === antes.errors && despues.filas === antes.filas,
+ `fallos ${antes.errors} → ${despues.errors}, filas del resumen ${antes.filas} → ${despues.filas}`,
+ )
+ check(
+ 'carteles: soltar pasado el borde no resta puntos',
+ puntosDespues === puntosAntes,
+ `${puntosAntes} → ${puntosDespues}`,
+ )
+ check(
+ 'carteles: la pieza no se coloca en el cartel de al lado',
+ despues.colocadas === antes.colocadas,
+ `${antes.colocadas} → ${despues.colocadas} colocadas`,
+ )
+
+ // Control: sobre su propio cartel sí coloca y no cuesta nada, tomando la línea
+ // base DESPUÉS de la suelta anterior para que mida solo lo que hace esta. Sin el
+ // control, la negativa de arriba pasaría aunque el arrastre no soltara nada.
+ const colocado = await arrastra(encima)
+ const final = await juego()
+ check(
+ 'carteles: sobre su propio cartel la pieza se coloca',
+ colocado && final.colocadas === despues.colocadas + 1,
+ `${despues.colocadas} → ${final.colocadas} colocadas`,
+ )
+ check(
+ 'carteles: colocar bien no cuesta ningún fallo',
+ final.errors === despues.errors,
+ `${despues.errors} → ${final.errors}`,
+ )
+ check('carteles: sin errores de página', examen.errors.length === 0, examen.errors.join(' | '))
+ await examen.page.close()
+}
+
/**
* Secciones de la pasada, en orden. `ONLY=examSummary,loadingBar` acota la
* ejecución a las nombradas (para falsar un cambio sin pagar la pasada entera).
@@ -2578,6 +2845,7 @@ const SECTIONS = [
['stageBanner', stageBanner],
['componentZoom', componentZoom],
['examIdentifyHint', examIdentifyHint],
+ ['identifyCards', identifyCards],
]
const ONLY = process.env.ONLY