use super::*; #[cfg(test)] mod tests { use super::*; /// El laboratorio corre una ley autorada del catálogo y forma estructura: el campo /// visible desarrolla rango espacial (certifica símbolos + params + programa + step, /// la cadena de app completa, sin GPU ni screenshot). #[test] fn lab_corre_ley_del_catalogo_y_forma_patron() { let ley = law_presets().into_iter().find(|(n, _)| *n == "reacción-difusión").unwrap().1; let LeyKind::Ecuacion { campos, .. } = &ley else { panic!("es Ecuacion") }; let prog = ley.compile_ecuacion().unwrap().expect("compila"); let mut lab = LawLab::build(1, 1, campos, Ok(prog), 1); let params = ley.ecuacion_param_values(); for _ in 0..80 { lab.step(¶ms); // 80 · LAB_SUBSTEPS pasos } let (mut lo, mut hi) = (f32::INFINITY, f32::NEG_INFINITY); for z in 0..LAB_DIM { for x in 0..LAB_DIM { let v = lab.engine.get(1, x, 0, z); lo = lo.min(v); hi = hi.max(v); } } assert!(hi - lo > 0.05, "la ley formó patrón en el lab (rango={})", hi - lo); // El heatmap se construye a la resolución del lab sin panics. let _img = heatmap_image(&lab); } /// Ciclar el catálogo desde la difusión por defecto entrega la reacción‑difusión. #[test] fn preset_cicla_al_siguiente() { let n = next_preset(&LeyKind::ecuacion_default()); match &n { LeyKind::Ecuacion { campos, .. } => assert_eq!(campos.len(), 2), _ => panic!("sigue siendo Ecuacion"), } } /// Cada rótulo del vestuario existe en los TRES catálogos. Una clave que falta no /// da error: muestra el identificador crudo, y sólo en la máquina del que habla el /// idioma que faltó. Recorre todos los peinados y sombreros por la MISMA función /// que usa el editor, así una prenda nueva sin su clave se ve acá. #[test] fn el_vestuario_se_lee_en_los_tres_idiomas() { let mut claves: Vec = [ "voxelstudio-tab-wardrobe", "voxelstudio-hair", "voxelstudio-hat", "voxelstudio-glasses-on", "voxelstudio-glasses-off", "voxelstudio-beard", "voxelstudio-skirt", "voxelstudio-cloak", "voxelstudio-color-hat", "voxelstudio-color-skirt", "voxelstudio-color-cloak", "voxelstudio-species", "voxelstudio-species-none", ] .iter() .map(|s| s.to_string()) .collect(); for p in Peinado::TODOS { let a = llimphi_voxel::Atuendo::con(p); claves.push(format!("voxelstudio-{}", clave_prenda(&a, "voxelstudio-hair"))); } for s in Sombrero::TODOS { let a = llimphi_voxel::Atuendo { sombrero: s, ..Default::default() }; claves.push(format!("voxelstudio-{}", clave_prenda(&a, "voxelstudio-hat"))); } // Diez peinados y siete sombreros: ninguno comparte clave con otro. let mut unicas = claves.clone(); unicas.sort(); unicas.dedup(); assert_eq!(unicas.len(), claves.len(), "dos prendas con la misma clave"); for idioma in ["es-PE", "en-US", "qu-PE"] { rimay_localize::set_locale(idioma).unwrap(); for k in &claves { let txt = rimay_localize::t(k); assert_ne!(&txt, k, "{idioma}: falta {k}"); assert!(!txt.trim().is_empty(), "{idioma}: {k} vacía"); } } } /// Las fórmulas de peso de los impulsos de fábrica parsean con la MISMA tabla de /// símbolos que expone el editor de conducta — guarda contra que se desincronicen los /// nombres de percepts/params entre el pie de ayuda y el parser. Y `materializar` /// siembra los impulsos de fábrica en un ser que no tenía. #[test] fn conducta_impulsos_de_fabrica_coherentes_con_el_editor() { let sym = llimphi_voxel::Conducta::symbols(); for imp in llimphi_voxel::default_impulsos() { assert!( llimphi_voxel::Expr::parse(&imp.peso, &sym).is_ok(), "la fórmula de fábrica «{}» parsea con los símbolos del editor", imp.peso ); } let mut c = CharSpec::new("x", Age::Adult); c.conducta.impulsos.clear(); materializar_impulsos(&mut c); assert_eq!(c.conducta.impulsos.len(), llimphi_voxel::default_impulsos().len()); // Ciclar la primitiva del primer impulso lo mueve a la siguiente. let antes = c.conducta.impulsos[0].primitiva; c.conducta.impulsos[0].primitiva = antes.next(); assert_ne!(antes, c.conducta.impulsos[0].primitiva); } /// **El editor de minerales funciona de punta a punta**: despachando los `Msg` /// reales por `update()` (con un handle de test), cada factor del mineral cambia /// en el proyecto y sube `gen` (lo que dispara la regeneración del preview). Es la /// prueba headless de que arrastrar los sliders SÍ modifica el mundo. #[test] fn editar_mineral_recorre_update_y_cambia_el_bioma() { let h = Handle::for_test(); let mut m = demo_model(); m.level = Level::Biomas; m.tool_tab = 3; let bid = m.project.biomas.first().map(|b| b.id).expect("hay biomas"); m.sel[3] = bid; let g0 = m.gen; let n_min = m.project.bioma(bid).unwrap().minerales.len(); assert!(n_min > 0, "el bioma de arranque trae minerales"); // Agregar un mineral por el mensaje real. m = Studio::update(m, Msg::AddBiomaMineral, &h); assert_eq!(m.project.bioma(bid).unwrap().minerales.len(), n_min + 1); // Editar cada factor del último mineral por su `Msg`. let i = n_min; // índice del recién agregado let tipo0 = m.project.bioma(bid).unwrap().minerales[i].mineral; m = Studio::update(m, Msg::CycleMineralTipo(i), &h); assert_ne!(tipo0, m.project.bioma(bid).unwrap().minerales[i].mineral, "el tipo cambió"); let asp0 = m.project.bioma(bid).unwrap().minerales[i].aspecto; m = Studio::update(m, Msg::CycleMineralAspecto(i), &h); assert_ne!(asp0, m.project.bioma(bid).unwrap().minerales[i].aspecto, "el aspecto cambió"); m = Studio::update(m, Msg::SetMineralBrillo(i, 1.0), &h); assert_eq!(m.project.bioma(bid).unwrap().minerales[i].brillo, 1.0, "brillo al máximo"); m = Studio::update(m, Msg::SetMineralDureza(i, 9.0), &h); assert_eq!(m.project.bioma(bid).unwrap().minerales[i].dureza, 9.0, "dureza editada"); m = Studio::update(m, Msg::SetMineralColor(i, 0, 1.0), &h); assert_eq!(m.project.bioma(bid).unwrap().minerales[i].color[0], 255, "color R al tope"); m = Studio::update(m, Msg::SetMineralAbundancia(i, 0.9), &h); assert_eq!(m.project.bioma(bid).unwrap().minerales[i].abundancia, 0.9, "abundancia editada"); assert!(m.gen > g0, "cada edición subió gen → el preview se regenera"); // Y el tab Objetos: ciclar la forma de un objeto también viaja por update. let bid_flora = m.project.biomas.iter().find(|b| !b.objetos.is_empty()).map(|b| b.id); if let Some(fid) = bid_flora { m.sel[3] = fid; let f0 = m.project.bioma(fid).unwrap().objetos[0].forma; m = Studio::update(m, Msg::CycleObjetoForma(0), &h); assert_ne!(f0, m.project.bioma(fid).unwrap().objetos[0].forma, "la forma del objeto cambió"); } } /// **El editor de interiores coloca muebles**: despachando los `Msg` reales por /// `update()`, cambiar el pincel y clickear el piso (PlaceMuebleAt) agrega una /// pieza al interior seleccionado, quitar/vaciar las saca, y editar la sala sube /// `gen`. Prueba headless de colocar-con-el-mouse (la proyección click→celda la /// hace `pick_world`, que necesita GPU; aquí probamos el camino de datos). #[test] fn editor_interiores_coloca_y_edita() { let h = Handle::for_test(); let mut m = demo_model(); m.level = Level::Interiores; let iid = m.project.interiores.first().map(|i| i.id).expect("hay un interior"); m.sel[6] = iid; let n0 = m.project.interiores.iter().find(|i| i.id == iid).unwrap().piezas.len(); // Elegir un mueble-pincel y orientación por los mensajes reales. let brush0 = m.mueble_brush; m = Studio::update(m, Msg::CycleMuebleBrush, &h); assert_ne!(brush0, m.mueble_brush, "el pincel cambió"); m = Studio::update(m, Msg::CycleMuebleFacing, &h); assert_eq!(m.mueble_facing, 1, "la orientación giró"); // Colocar sobre una celda del piso. let g0 = m.gen; m = Studio::update(m, Msg::PlaceMuebleAt(6.0, 5.0), &h); let it = m.project.interiores.iter().find(|i| i.id == iid).unwrap(); assert_eq!(it.piezas.len(), n0 + 1, "se colocó una pieza"); assert_eq!(it.piezas.last().unwrap().mueble, m.mueble_brush); assert!(m.gen > g0, "colocar regeneró el preview"); // Quitar la última y editar la sala. m = Studio::update(m, Msg::RemoveLastPieza, &h); assert_eq!(m.project.interiores.iter().find(|i| i.id == iid).unwrap().piezas.len(), n0); m = Studio::update(m, Msg::SetInteriorAncho(30.0), &h); assert_eq!(m.project.interiores.iter().find(|i| i.id == iid).unwrap().ancho, 30); m = Studio::update(m, Msg::ToggleInteriorMuros, &h); assert!(!m.project.interiores.iter().find(|i| i.id == iid).unwrap().muros); } /// La rampa cubre de oscuro a claro en los extremos (heatmap legible). #[test] fn rampa_va_de_oscuro_a_claro() { let bajo = ramp(0.0); let alto = ramp(1.0); assert!(bajo.iter().map(|&c| c as u32).sum::() < 100, "extremo bajo oscuro"); assert!(alto[0] > 200 && alto[1] > 200, "extremo alto claro"); } /// **El editor convierte un ser en elefante y lo devuelve a persona**, por los /// `Msg` reales: hacia adelante llega al elefante, cada paso sube `gen` (lo que /// refresca el preview), y hacia atrás desde el primero vuelve a «ninguna». #[test] fn el_editor_vuelve_elefante_a_un_ser_y_lo_devuelve() { use llimphi_voxel::Bicho; let h = Handle::for_test(); let mut m = demo_model(); m.level = Level::Seres; m.sel[2] = m.project.seres[0].id; assert_eq!(sel_sere(&m).unwrap().bicho, None); let mut pasos = 0; while sel_sere(&m).unwrap().bicho != Some(Bicho::Elefante) { let g = m.gen; m = Studio::update(m, Msg::CycleSereBicho(false), &h); assert_eq!(m.gen, g + 1, "cambiar la especie refresca el preview"); pasos += 1; assert!(pasos <= Bicho::ALL.len(), "el elefante no aparece ciclando"); } assert!(sel_sere(&m).unwrap().es_sdf(), "el elefante se pinta como SDF"); while sel_sere(&m).unwrap().bicho.is_some() { m = Studio::update(m, Msg::CycleSereBicho(true), &h); } assert!(sel_sere(&m).unwrap().bicho.is_none(), "hacia atrás vuelve a persona"); } /// **La utilería se dirige desde el editor**, por los `Msg` reales: una pieza /// nueva nace en la mano del primer actor, cambia de cosa, pasa al mundo, se /// mueve y se quita. Y cada rótulo de la pestaña está en los tres idiomas. #[test] fn la_utileria_se_dirige_desde_el_editor_y_se_lee_en_los_tres_idiomas() { let h = Handle::for_test(); let mut m = demo_model(); m.level = Level::Escenas; let id = m.project.alloc_id(); let mut sc = SceneSpec::walk_and_emote("prendedor", 0, 2, Clip::Wave, [96, 24, 96]); sc.id = id; m.project.escenas.push(sc); m.sel[5] = id; m.time = 1.5; m = Studio::update(m, Msg::AddUtileria, &h); let u = &sel_scene(&m).unwrap().utileria[0]; assert_eq!((u.cosa, u.lleva, u.desde), (Utileria::Estrella, Some(0), 1.5)); m = Studio::update(m, Msg::CycleUtileriaCosa, &h); assert_eq!(sel_scene(&m).unwrap().utileria[0].cosa, Utileria::Flor); // Nadie → actor 0 → actor 1 → nadie: desde el actor 0, dos toques la sueltan. m = Studio::update(m, Msg::CycleUtileriaLleva, &h); assert_eq!(sel_scene(&m).unwrap().utileria[0].lleva, Some(1)); m = Studio::update(m, Msg::CycleUtileriaLleva, &h); assert_eq!(sel_scene(&m).unwrap().utileria[0].lleva, None); m = Studio::update(m, Msg::SetUtileria(UtileriaField::X, 33.0), &h); m = Studio::update(m, Msg::SetUtileria(UtileriaField::Giro, 9.0), &h); let u = &sel_scene(&m).unwrap().utileria[0]; assert_eq!(u.pos[0], 33.0); assert_eq!(u.giro, 1.0, "el giro se acota"); // El editor se arma (con la pieza en el mundo, con sus sliders de lugar). assert!(utileria_tools(&m, sel_scene(&m).unwrap()).len() > 8); m = Studio::update(m, Msg::RemoveUtileria, &h); assert!(sel_scene(&m).unwrap().utileria.is_empty()); let mut claves: Vec<&str> = vec![ "voxelstudio-tab-props", "voxelstudio-prop-row", "voxelstudio-prop-empty", "voxelstudio-prop-add", "voxelstudio-prop-remove", "voxelstudio-prop-kind", "voxelstudio-prop-held-by", "voxelstudio-prop-held-nobody", "voxelstudio-prop-x", "voxelstudio-prop-y", "voxelstudio-prop-z", "voxelstudio-prop-size", "voxelstudio-prop-from", "voxelstudio-prop-spin", ]; claves.extend(Utileria::ALL.iter().map(|&u| clave_utileria(u))); for idioma in ["es-PE", "en-US", "qu-PE"] { rimay_localize::set_locale(idioma).unwrap(); for k in &claves { let txt = rimay_localize::t(k); assert_ne!(&txt, k, "{idioma}: falta {k}"); } } } /// **La noche se prende desde el editor**: el botón de cielo da vuelta día ↔ /// noche, las perillas se acotan, y los rótulos están en los tres idiomas. #[test] fn la_noche_se_prende_desde_el_editor_y_se_lee_en_los_tres_idiomas() { let h = Handle::for_test(); let mut m = demo_model(); m.level = Level::Escenas; let id = m.project.alloc_id(); let mut sc = SceneSpec::walk_and_emote("la estrella", 0, 1, Clip::Wave, [96, 24, 96]); sc.id = id; m.project.escenas.push(sc); m.sel[5] = id; assert!(sel_scene(&m).unwrap().cielo.is_none(), "una escena nace de día"); let de_dia = cielo_tools(&m, sel_scene(&m).unwrap()).len(); m = Studio::update(m, Msg::ToggleNoche, &h); assert_eq!(sel_scene(&m).unwrap().cielo, Some(Cielo::noche())); assert!(cielo_tools(&m, sel_scene(&m).unwrap()).len() > de_dia, "de noche aparecen las perillas"); m = Studio::update(m, Msg::SetCielo(CieloField::Estrellas, 3.0), &h); m = Studio::update(m, Msg::SetCielo(CieloField::Luna, 0.09), &h); let c = sel_scene(&m).unwrap().cielo.unwrap(); assert_eq!((c.estrellas, c.luna_radio), (1.0, 0.09), "las estrellas se acotan a 1"); m = Studio::update(m, Msg::ToggleNoche, &h); assert!(sel_scene(&m).unwrap().cielo.is_none(), "y vuelve el día"); for idioma in ["es-PE", "en-US", "qu-PE"] { rimay_localize::set_locale(idioma).unwrap(); for k in ["voxelstudio-sky-day", "voxelstudio-sky-night", "voxelstudio-sky-stars", "voxelstudio-sky-moon"] { let txt = rimay_localize::t(k); assert_ne!(&txt, k, "{idioma}: falta {k}"); } } } /// **El decorado se arma desde el editor**: una construcción nueva nace como /// palacio al fondo del recorrido, cambia de estilo, de variante y de lugar, y /// se quita. Y cada rótulo —incluido el nombre de cada estilo de casa— está /// en los tres idiomas. #[test] fn el_decorado_se_arma_desde_el_editor_y_se_lee_en_los_tres_idiomas() { let h = Handle::for_test(); let mut m = demo_model(); m.level = Level::Escenas; let id = m.project.alloc_id(); let mut sc = SceneSpec::walk_and_emote("el palacio", 0, 1, Clip::Wave, [96, 24, 96]); sc.id = id; let fin = *sc.actors[0].keys.last().unwrap(); m.project.escenas.push(sc); m.sel[5] = id; m = Studio::update(m, Msg::AddDecorado, &h); let d = &sel_scene(&m).unwrap().decorado[0]; assert_eq!(d.casa, CasaTipo::Palacio); assert_eq!(d.pos, [fin.gx, fin.gz + 18.0], "nace detrás del final del recorrido"); m = Studio::update(m, Msg::CycleDecoradoCasa, &h); assert_ne!(sel_scene(&m).unwrap().decorado[0].casa, CasaTipo::Palacio); m = Studio::update(m, Msg::OtraVarianteDecorado, &h); assert_eq!(sel_scene(&m).unwrap().decorado[0].semilla, 1); m = Studio::update(m, Msg::SetDecorado(DecoradoField::X, 40.4), &h); assert_eq!(sel_scene(&m).unwrap().decorado[0].pos[0], 40.0, "se planta en una columna entera"); assert!(decorado_tools(&m, sel_scene(&m).unwrap()).len() > 6); m = Studio::update(m, Msg::RemoveDecorado, &h); assert!(sel_scene(&m).unwrap().decorado.is_empty()); let mut claves: Vec<&str> = vec![ "voxelstudio-tab-sets", "voxelstudio-set-row", "voxelstudio-set-empty", "voxelstudio-set-add", "voxelstudio-set-remove", "voxelstudio-set-kind", "voxelstudio-set-variant", "voxelstudio-set-x", "voxelstudio-set-z", ]; claves.extend(CasaTipo::ALL.iter().map(|&c| clave_casa(c))); for idioma in ["es-PE", "en-US", "qu-PE"] { rimay_localize::set_locale(idioma).unwrap(); for k in &claves { let txt = rimay_localize::t(k); assert_ne!(&txt, k, "{idioma}: falta {k}"); } } } }