tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — 132 miembros, 79 widgets, la galería vuelve y licencia dual
1use super::*;
2
3#[cfg(test)]
4mod tests {
5 use super::*;
6
7 /// El laboratorio corre una ley autorada del catálogo y forma estructura: el campo
8 /// visible desarrolla rango espacial (certifica símbolos + params + programa + step,
9 /// la cadena de app completa, sin GPU ni screenshot).
10 #[test]
11 fn lab_corre_ley_del_catalogo_y_forma_patron() {
12 let ley = law_presets().into_iter().find(|(n, _)| *n == "reacción-difusión").unwrap().1;
13 let LeyKind::Ecuacion { campos, .. } = &ley else { panic!("es Ecuacion") };
14 let prog = ley.compile_ecuacion().unwrap().expect("compila");
15 let mut lab = LawLab::build(1, 1, campos, Ok(prog), 1);
16 let params = ley.ecuacion_param_values();
17 for _ in 0..80 {
18 lab.step(¶ms); // 80 · LAB_SUBSTEPS pasos
19 }
20 let (mut lo, mut hi) = (f32::INFINITY, f32::NEG_INFINITY);
21 for z in 0..LAB_DIM {
22 for x in 0..LAB_DIM {
23 let v = lab.engine.get(1, x, 0, z);
24 lo = lo.min(v);
25 hi = hi.max(v);
26 }
27 }
28 assert!(hi - lo > 0.05, "la ley formó patrón en el lab (rango={})", hi - lo);
29 // El heatmap se construye a la resolución del lab sin panics.
30 let _img = heatmap_image(&lab);
31 }
32
33 /// Ciclar el catálogo desde la difusión por defecto entrega la reacción‑difusión.
34 #[test]
35 fn preset_cicla_al_siguiente() {
36 let n = next_preset(&LeyKind::ecuacion_default());
37 match &n {
38 LeyKind::Ecuacion { campos, .. } => assert_eq!(campos.len(), 2),
39 _ => panic!("sigue siendo Ecuacion"),
40 }
41 }
42
43 /// Cada rótulo del vestuario existe en los TRES catálogos. Una clave que falta no
44 /// da error: muestra el identificador crudo, y sólo en la máquina del que habla el
45 /// idioma que faltó. Recorre todos los peinados y sombreros por la MISMA función
46 /// que usa el editor, así una prenda nueva sin su clave se ve acá.
47 #[test]
48 fn el_vestuario_se_lee_en_los_tres_idiomas() {
49 let mut claves: Vec<String> = [
50 "voxelstudio-tab-wardrobe",
51 "voxelstudio-hair",
52 "voxelstudio-hat",
53 "voxelstudio-glasses-on",
54 "voxelstudio-glasses-off",
55 "voxelstudio-beard",
56 "voxelstudio-skirt",
57 "voxelstudio-cloak",
58 "voxelstudio-color-hat",
59 "voxelstudio-color-skirt",
60 "voxelstudio-color-cloak",
61 "voxelstudio-species",
62 "voxelstudio-species-none",
63 ]
64 .iter()
65 .map(|s| s.to_string())
66 .collect();
67 for p in Peinado::TODOS {
68 let a = llimphi_voxel::Atuendo::con(p);
69 claves.push(format!("voxelstudio-{}", clave_prenda(&a, "voxelstudio-hair")));
70 }
71 for s in Sombrero::TODOS {
72 let a = llimphi_voxel::Atuendo { sombrero: s, ..Default::default() };
73 claves.push(format!("voxelstudio-{}", clave_prenda(&a, "voxelstudio-hat")));
74 }
75 // Diez peinados y siete sombreros: ninguno comparte clave con otro.
76 let mut unicas = claves.clone();
77 unicas.sort();
78 unicas.dedup();
79 assert_eq!(unicas.len(), claves.len(), "dos prendas con la misma clave");
80 for idioma in ["es-PE", "en-US", "qu-PE"] {
81 rimay_localize::set_locale(idioma).unwrap();
82 for k in &claves {
83 let txt = rimay_localize::t(k);
84 assert_ne!(&txt, k, "{idioma}: falta {k}");
85 assert!(!txt.trim().is_empty(), "{idioma}: {k} vacía");
86 }
87 }
88 }
89
90 /// Las fórmulas de peso de los impulsos de fábrica parsean con la MISMA tabla de
91 /// símbolos que expone el editor de conducta — guarda contra que se desincronicen los
92 /// nombres de percepts/params entre el pie de ayuda y el parser. Y `materializar`
93 /// siembra los impulsos de fábrica en un ser que no tenía.
94 #[test]
95 fn conducta_impulsos_de_fabrica_coherentes_con_el_editor() {
96 let sym = llimphi_voxel::Conducta::symbols();
97 for imp in llimphi_voxel::default_impulsos() {
98 assert!(
99 llimphi_voxel::Expr::parse(&imp.peso, &sym).is_ok(),
100 "la fórmula de fábrica «{}» parsea con los símbolos del editor",
101 imp.peso
102 );
103 }
104 let mut c = CharSpec::new("x", Age::Adult);
105 c.conducta.impulsos.clear();
106 materializar_impulsos(&mut c);
107 assert_eq!(c.conducta.impulsos.len(), llimphi_voxel::default_impulsos().len());
108 // Ciclar la primitiva del primer impulso lo mueve a la siguiente.
109 let antes = c.conducta.impulsos[0].primitiva;
110 c.conducta.impulsos[0].primitiva = antes.next();
111 assert_ne!(antes, c.conducta.impulsos[0].primitiva);
112 }
113
114 /// **El editor de minerales funciona de punta a punta**: despachando los `Msg`
115 /// reales por `update()` (con un handle de test), cada factor del mineral cambia
116 /// en el proyecto y sube `gen` (lo que dispara la regeneración del preview). Es la
117 /// prueba headless de que arrastrar los sliders SÍ modifica el mundo.
118 #[test]
119 fn editar_mineral_recorre_update_y_cambia_el_bioma() {
120 let h = Handle::for_test();
121 let mut m = demo_model();
122 m.level = Level::Biomas;
123 m.tool_tab = 3;
124 let bid = m.project.biomas.first().map(|b| b.id).expect("hay biomas");
125 m.sel[3] = bid;
126 let g0 = m.gen;
127 let n_min = m.project.bioma(bid).unwrap().minerales.len();
128 assert!(n_min > 0, "el bioma de arranque trae minerales");
129
130 // Agregar un mineral por el mensaje real.
131 m = Studio::update(m, Msg::AddBiomaMineral, &h);
132 assert_eq!(m.project.bioma(bid).unwrap().minerales.len(), n_min + 1);
133
134 // Editar cada factor del último mineral por su `Msg`.
135 let i = n_min; // índice del recién agregado
136 let tipo0 = m.project.bioma(bid).unwrap().minerales[i].mineral;
137 m = Studio::update(m, Msg::CycleMineralTipo(i), &h);
138 assert_ne!(tipo0, m.project.bioma(bid).unwrap().minerales[i].mineral, "el tipo cambió");
139
140 let asp0 = m.project.bioma(bid).unwrap().minerales[i].aspecto;
141 m = Studio::update(m, Msg::CycleMineralAspecto(i), &h);
142 assert_ne!(asp0, m.project.bioma(bid).unwrap().minerales[i].aspecto, "el aspecto cambió");
143
144 m = Studio::update(m, Msg::SetMineralBrillo(i, 1.0), &h);
145 assert_eq!(m.project.bioma(bid).unwrap().minerales[i].brillo, 1.0, "brillo al máximo");
146
147 m = Studio::update(m, Msg::SetMineralDureza(i, 9.0), &h);
148 assert_eq!(m.project.bioma(bid).unwrap().minerales[i].dureza, 9.0, "dureza editada");
149
150 m = Studio::update(m, Msg::SetMineralColor(i, 0, 1.0), &h);
151 assert_eq!(m.project.bioma(bid).unwrap().minerales[i].color[0], 255, "color R al tope");
152
153 m = Studio::update(m, Msg::SetMineralAbundancia(i, 0.9), &h);
154 assert_eq!(m.project.bioma(bid).unwrap().minerales[i].abundancia, 0.9, "abundancia editada");
155
156 assert!(m.gen > g0, "cada edición subió gen → el preview se regenera");
157
158 // Y el tab Objetos: ciclar la forma de un objeto también viaja por update.
159 let bid_flora = m.project.biomas.iter().find(|b| !b.objetos.is_empty()).map(|b| b.id);
160 if let Some(fid) = bid_flora {
161 m.sel[3] = fid;
162 let f0 = m.project.bioma(fid).unwrap().objetos[0].forma;
163 m = Studio::update(m, Msg::CycleObjetoForma(0), &h);
164 assert_ne!(f0, m.project.bioma(fid).unwrap().objetos[0].forma, "la forma del objeto cambió");
165 }
166 }
167
168 /// **El editor de interiores coloca muebles**: despachando los `Msg` reales por
169 /// `update()`, cambiar el pincel y clickear el piso (PlaceMuebleAt) agrega una
170 /// pieza al interior seleccionado, quitar/vaciar las saca, y editar la sala sube
171 /// `gen`. Prueba headless de colocar-con-el-mouse (la proyección click→celda la
172 /// hace `pick_world`, que necesita GPU; aquí probamos el camino de datos).
173 #[test]
174 fn editor_interiores_coloca_y_edita() {
175 let h = Handle::for_test();
176 let mut m = demo_model();
177 m.level = Level::Interiores;
178 let iid = m.project.interiores.first().map(|i| i.id).expect("hay un interior");
179 m.sel[6] = iid;
180 let n0 = m.project.interiores.iter().find(|i| i.id == iid).unwrap().piezas.len();
181
182 // Elegir un mueble-pincel y orientación por los mensajes reales.
183 let brush0 = m.mueble_brush;
184 m = Studio::update(m, Msg::CycleMuebleBrush, &h);
185 assert_ne!(brush0, m.mueble_brush, "el pincel cambió");
186 m = Studio::update(m, Msg::CycleMuebleFacing, &h);
187 assert_eq!(m.mueble_facing, 1, "la orientación giró");
188
189 // Colocar sobre una celda del piso.
190 let g0 = m.gen;
191 m = Studio::update(m, Msg::PlaceMuebleAt(6.0, 5.0), &h);
192 let it = m.project.interiores.iter().find(|i| i.id == iid).unwrap();
193 assert_eq!(it.piezas.len(), n0 + 1, "se colocó una pieza");
194 assert_eq!(it.piezas.last().unwrap().mueble, m.mueble_brush);
195 assert!(m.gen > g0, "colocar regeneró el preview");
196
197 // Quitar la última y editar la sala.
198 m = Studio::update(m, Msg::RemoveLastPieza, &h);
199 assert_eq!(m.project.interiores.iter().find(|i| i.id == iid).unwrap().piezas.len(), n0);
200 m = Studio::update(m, Msg::SetInteriorAncho(30.0), &h);
201 assert_eq!(m.project.interiores.iter().find(|i| i.id == iid).unwrap().ancho, 30);
202 m = Studio::update(m, Msg::ToggleInteriorMuros, &h);
203 assert!(!m.project.interiores.iter().find(|i| i.id == iid).unwrap().muros);
204 }
205
206 /// La rampa cubre de oscuro a claro en los extremos (heatmap legible).
207 #[test]
208 fn rampa_va_de_oscuro_a_claro() {
209 let bajo = ramp(0.0);
210 let alto = ramp(1.0);
211 assert!(bajo.iter().map(|&c| c as u32).sum::<u32>() < 100, "extremo bajo oscuro");
212 assert!(alto[0] > 200 && alto[1] > 200, "extremo alto claro");
213 }
214
215 /// **El editor convierte un ser en elefante y lo devuelve a persona**, por los
216 /// `Msg` reales: hacia adelante llega al elefante, cada paso sube `gen` (lo que
217 /// refresca el preview), y hacia atrás desde el primero vuelve a «ninguna».
218 #[test]
219 fn el_editor_vuelve_elefante_a_un_ser_y_lo_devuelve() {
220 use llimphi_voxel::Bicho;
221 let h = Handle::for_test();
222 let mut m = demo_model();
223 m.level = Level::Seres;
224 m.sel[2] = m.project.seres[0].id;
225 assert_eq!(sel_sere(&m).unwrap().bicho, None);
226 let mut pasos = 0;
227 while sel_sere(&m).unwrap().bicho != Some(Bicho::Elefante) {
228 let g = m.gen;
229 m = Studio::update(m, Msg::CycleSereBicho(false), &h);
230 assert_eq!(m.gen, g + 1, "cambiar la especie refresca el preview");
231 pasos += 1;
232 assert!(pasos <= Bicho::ALL.len(), "el elefante no aparece ciclando");
233 }
234 assert!(sel_sere(&m).unwrap().es_sdf(), "el elefante se pinta como SDF");
235 while sel_sere(&m).unwrap().bicho.is_some() {
236 m = Studio::update(m, Msg::CycleSereBicho(true), &h);
237 }
238 assert!(sel_sere(&m).unwrap().bicho.is_none(), "hacia atrás vuelve a persona");
239 }
240
241 /// **La utilería se dirige desde el editor**, por los `Msg` reales: una pieza
242 /// nueva nace en la mano del primer actor, cambia de cosa, pasa al mundo, se
243 /// mueve y se quita. Y cada rótulo de la pestaña está en los tres idiomas.
244 #[test]
245 fn la_utileria_se_dirige_desde_el_editor_y_se_lee_en_los_tres_idiomas() {
246 let h = Handle::for_test();
247 let mut m = demo_model();
248 m.level = Level::Escenas;
249 let id = m.project.alloc_id();
250 let mut sc = SceneSpec::walk_and_emote("prendedor", 0, 2, Clip::Wave, [96, 24, 96]);
251 sc.id = id;
252 m.project.escenas.push(sc);
253 m.sel[5] = id;
254 m.time = 1.5;
255 m = Studio::update(m, Msg::AddUtileria, &h);
256 let u = &sel_scene(&m).unwrap().utileria[0];
257 assert_eq!((u.cosa, u.lleva, u.desde), (Utileria::Estrella, Some(0), 1.5));
258 m = Studio::update(m, Msg::CycleUtileriaCosa, &h);
259 assert_eq!(sel_scene(&m).unwrap().utileria[0].cosa, Utileria::Flor);
260 // Nadie → actor 0 → actor 1 → nadie: desde el actor 0, dos toques la sueltan.
261 m = Studio::update(m, Msg::CycleUtileriaLleva, &h);
262 assert_eq!(sel_scene(&m).unwrap().utileria[0].lleva, Some(1));
263 m = Studio::update(m, Msg::CycleUtileriaLleva, &h);
264 assert_eq!(sel_scene(&m).unwrap().utileria[0].lleva, None);
265 m = Studio::update(m, Msg::SetUtileria(UtileriaField::X, 33.0), &h);
266 m = Studio::update(m, Msg::SetUtileria(UtileriaField::Giro, 9.0), &h);
267 let u = &sel_scene(&m).unwrap().utileria[0];
268 assert_eq!(u.pos[0], 33.0);
269 assert_eq!(u.giro, 1.0, "el giro se acota");
270 // El editor se arma (con la pieza en el mundo, con sus sliders de lugar).
271 assert!(utileria_tools(&m, sel_scene(&m).unwrap()).len() > 8);
272 m = Studio::update(m, Msg::RemoveUtileria, &h);
273 assert!(sel_scene(&m).unwrap().utileria.is_empty());
274
275 let mut claves: Vec<&str> = vec![
276 "voxelstudio-tab-props",
277 "voxelstudio-prop-row",
278 "voxelstudio-prop-empty",
279 "voxelstudio-prop-add",
280 "voxelstudio-prop-remove",
281 "voxelstudio-prop-kind",
282 "voxelstudio-prop-held-by",
283 "voxelstudio-prop-held-nobody",
284 "voxelstudio-prop-x",
285 "voxelstudio-prop-y",
286 "voxelstudio-prop-z",
287 "voxelstudio-prop-size",
288 "voxelstudio-prop-from",
289 "voxelstudio-prop-spin",
290 ];
291 claves.extend(Utileria::ALL.iter().map(|&u| clave_utileria(u)));
292 for idioma in ["es-PE", "en-US", "qu-PE"] {
293 rimay_localize::set_locale(idioma).unwrap();
294 for k in &claves {
295 let txt = rimay_localize::t(k);
296 assert_ne!(&txt, k, "{idioma}: falta {k}");
297 }
298 }
299 }
300
301 /// **La noche se prende desde el editor**: el botón de cielo da vuelta día ↔
302 /// noche, las perillas se acotan, y los rótulos están en los tres idiomas.
303 #[test]
304 fn la_noche_se_prende_desde_el_editor_y_se_lee_en_los_tres_idiomas() {
305 let h = Handle::for_test();
306 let mut m = demo_model();
307 m.level = Level::Escenas;
308 let id = m.project.alloc_id();
309 let mut sc = SceneSpec::walk_and_emote("la estrella", 0, 1, Clip::Wave, [96, 24, 96]);
310 sc.id = id;
311 m.project.escenas.push(sc);
312 m.sel[5] = id;
313 assert!(sel_scene(&m).unwrap().cielo.is_none(), "una escena nace de día");
314 let de_dia = cielo_tools(&m, sel_scene(&m).unwrap()).len();
315 m = Studio::update(m, Msg::ToggleNoche, &h);
316 assert_eq!(sel_scene(&m).unwrap().cielo, Some(Cielo::noche()));
317 assert!(cielo_tools(&m, sel_scene(&m).unwrap()).len() > de_dia, "de noche aparecen las perillas");
318 m = Studio::update(m, Msg::SetCielo(CieloField::Estrellas, 3.0), &h);
319 m = Studio::update(m, Msg::SetCielo(CieloField::Luna, 0.09), &h);
320 let c = sel_scene(&m).unwrap().cielo.unwrap();
321 assert_eq!((c.estrellas, c.luna_radio), (1.0, 0.09), "las estrellas se acotan a 1");
322 m = Studio::update(m, Msg::ToggleNoche, &h);
323 assert!(sel_scene(&m).unwrap().cielo.is_none(), "y vuelve el día");
324
325 for idioma in ["es-PE", "en-US", "qu-PE"] {
326 rimay_localize::set_locale(idioma).unwrap();
327 for k in ["voxelstudio-sky-day", "voxelstudio-sky-night", "voxelstudio-sky-stars", "voxelstudio-sky-moon"] {
328 let txt = rimay_localize::t(k);
329 assert_ne!(&txt, k, "{idioma}: falta {k}");
330 }
331 }
332 }
333
334 /// **El decorado se arma desde el editor**: una construcción nueva nace como
335 /// palacio al fondo del recorrido, cambia de estilo, de variante y de lugar, y
336 /// se quita. Y cada rótulo —incluido el nombre de cada estilo de casa— está
337 /// en los tres idiomas.
338 #[test]
339 fn el_decorado_se_arma_desde_el_editor_y_se_lee_en_los_tres_idiomas() {
340 let h = Handle::for_test();
341 let mut m = demo_model();
342 m.level = Level::Escenas;
343 let id = m.project.alloc_id();
344 let mut sc = SceneSpec::walk_and_emote("el palacio", 0, 1, Clip::Wave, [96, 24, 96]);
345 sc.id = id;
346 let fin = *sc.actors[0].keys.last().unwrap();
347 m.project.escenas.push(sc);
348 m.sel[5] = id;
349 m = Studio::update(m, Msg::AddDecorado, &h);
350 let d = &sel_scene(&m).unwrap().decorado[0];
351 assert_eq!(d.casa, CasaTipo::Palacio);
352 assert_eq!(d.pos, [fin.gx, fin.gz + 18.0], "nace detrás del final del recorrido");
353 m = Studio::update(m, Msg::CycleDecoradoCasa, &h);
354 assert_ne!(sel_scene(&m).unwrap().decorado[0].casa, CasaTipo::Palacio);
355 m = Studio::update(m, Msg::OtraVarianteDecorado, &h);
356 assert_eq!(sel_scene(&m).unwrap().decorado[0].semilla, 1);
357 m = Studio::update(m, Msg::SetDecorado(DecoradoField::X, 40.4), &h);
358 assert_eq!(sel_scene(&m).unwrap().decorado[0].pos[0], 40.0, "se planta en una columna entera");
359 assert!(decorado_tools(&m, sel_scene(&m).unwrap()).len() > 6);
360 m = Studio::update(m, Msg::RemoveDecorado, &h);
361 assert!(sel_scene(&m).unwrap().decorado.is_empty());
362
363 let mut claves: Vec<&str> = vec![
364 "voxelstudio-tab-sets",
365 "voxelstudio-set-row",
366 "voxelstudio-set-empty",
367 "voxelstudio-set-add",
368 "voxelstudio-set-remove",
369 "voxelstudio-set-kind",
370 "voxelstudio-set-variant",
371 "voxelstudio-set-x",
372 "voxelstudio-set-z",
373 ];
374 claves.extend(CasaTipo::ALL.iter().map(|&c| clave_casa(c)));
375 for idioma in ["es-PE", "en-US", "qu-PE"] {
376 rimay_localize::set_locale(idioma).unwrap();
377 for k in &claves {
378 let txt = rimay_localize::t(k);
379 assert_ne!(&txt, k, "{idioma}: falta {k}");
380 }
381 }
382 }
383}