// ============================================================================= // llimphi-voxel :: tests/documento — abrir, guardar, combinar, extraer // ----------------------------------------------------------------------------- // Certifica la administración por archivos: round-trip del format (RON y // postcard), combinar proyectos sin colisiones ni referencias rotas, y extraer // una escena con toda su clausura de dependencias como fragmento portable. // (`Project` no es `PartialEq` por historia, así que se compara por // serialización — el round-trip idempotente es prueba suficiente.) // ============================================================================= use llimphi_voxel::{Documento, ErrorDoc, Project, Suceso, TipoSuceso, MAGIA_DOC, VERSION_DOC}; #[test] fn round_trip_ron_preserva_el_proyecto() { let p = Project::starter(); let doc = Documento::nuevo(p.clone()); let texto = doc.a_ron().unwrap(); let recuperado = Documento::de_ron(&texto).unwrap(); // Re-serializar da el MISMO texto: el proyecto sobrevivió íntegro. assert_eq!(recuperado.a_ron().unwrap(), texto); assert_eq!(recuperado.magia, MAGIA_DOC); assert_eq!(recuperado.version, VERSION_DOC); assert_eq!(recuperado.proyecto.escenas.len(), p.escenas.len()); } #[test] fn round_trip_postcard_nativo() { let doc = Documento::nuevo(Project::starter()); let bytes = doc.a_bytes().unwrap(); let recuperado = Documento::de_bytes(&bytes).unwrap(); // El format nativo compacto también preserva el proyecto (comparado vía RON). assert_eq!(recuperado.a_ron().unwrap(), doc.a_ron().unwrap()); // Y es más chico que el RON legible. assert!(bytes.len() < doc.a_ron().unwrap().len()); } #[test] fn un_ron_ajeno_se_rechaza() { // Un RON que no parsea como documento se rechaza (no se malinterpreta). assert!(matches!( Documento::de_ron("(foo: 1, bar: 2)"), Err(ErrorDoc::Parsear(_)) )); } #[test] fn marca_ajena_pero_ron_valido_se_detecta() { let doc = Documento::nuevo(Project::starter()); let texto = doc.a_ron().unwrap().replace(MAGIA_DOC, "otra-cosa"); assert_eq!(Documento::de_ron(&texto).unwrap_err(), ErrorDoc::MagiaAjena); } #[test] fn version_futura_se_rechaza() { let mut doc = Documento::nuevo(Project::starter()); doc.version = VERSION_DOC + 1; let texto = doc.a_ron().unwrap(); assert_eq!( Documento::de_ron(&texto).unwrap_err(), ErrorDoc::VersionFutura(VERSION_DOC + 1) ); } #[test] fn el_starter_es_integro() { // El proyecto de arranque no tiene referencias rotas. assert!(Project::starter().validar_referencias().is_empty()); } #[test] fn combinar_no_colisiona_ni_rompe_referencias() { let mut a = Project::starter(); let b = Project::starter(); let (leyes_a, mats_a, seres_a, biomas_a, mundos_a, escenas_a) = ( a.leyes.len(), a.materiales.len(), a.seres.len(), a.biomas.len(), a.mundos.len(), a.escenas.len(), ); a.combinar(&b); // Se duplicó todo (dos starters). assert_eq!(a.leyes.len(), leyes_a + b.leyes.len()); assert_eq!(a.materiales.len(), mats_a + b.materiales.len()); assert_eq!(a.seres.len(), seres_a + b.seres.len()); assert_eq!(a.biomas.len(), biomas_a + b.biomas.len()); assert_eq!(a.mundos.len(), mundos_a + b.mundos.len()); assert_eq!(a.escenas.len(), escenas_a + b.escenas.len()); // Ningún id repetido en todo el proyecto combinado. let mut ids: Vec = a .leyes .iter() .map(|x| x.id) .chain(a.materiales.iter().map(|x| x.id)) .chain(a.seres.iter().map(|x| x.id)) .chain(a.biomas.iter().map(|x| x.id)) .chain(a.mundos.iter().map(|x| x.id)) .chain(a.escenas.iter().map(|x| x.id)) .collect(); let total = ids.len(); ids.sort_unstable(); ids.dedup(); assert_eq!(ids.len(), total, "hay ids repetidos tras combinar"); // Y TODAS las referencias cruzadas siguen resolviendo. let rotas = a.validar_referencias(); assert!(rotas.is_empty(), "combinar rompió referencias: {rotas:?}"); } #[test] fn extraer_escena_produce_un_fragmento_integro_y_minimo() { let p = Project::starter(); let escena_id = p.escenas[0].id; let frag = p.extraer_escena(escena_id).expect("la escena existe"); // El fragmento tiene exactamente una escena y es íntegro. assert_eq!(frag.escenas.len(), 1); assert_eq!(frag.escenas[0].id, escena_id); let rotas = frag.validar_referencias(); assert!(rotas.is_empty(), "fragmento roto: {rotas:?}"); // Es MÍNIMO: no arrastra el mundo/bioma que la escena no usa. El starter // tiene 2 mundos; la escena demo usa uno → el fragmento trae 1. assert_eq!(frag.mundos.len(), 1); assert!(frag.mundos.len() < p.mundos.len()); assert_eq!(frag.mundos[0].id, p.escenas[0].mundo); } #[test] fn extraer_luego_combinar_reconstituye_una_escena_en_otro_proyecto() { let origen = Project::starter(); let escena_id = origen.escenas[0].id; // Un proyecto destino vacío recibe SÓLO esa escena, con su clausura. let mut destino = Project { next_id: 1, ..Default::default() }; destino.combinar_escena(&origen, escena_id).unwrap(); assert_eq!(destino.escenas.len(), 1); assert!(destino.validar_referencias().is_empty()); // El destino quedó auto-contenido: resuelve el mundo de la escena. let esc = &destino.escenas[0]; assert!(destino.mundo(esc.mundo).is_some()); // Y los actores apuntan a seres válidos del destino. for a in &esc.actors { assert!(destino.seres.get(a.character).is_some()); } } #[test] fn combinar_escena_inexistente_es_error() { let mut destino = Project::starter(); let otro = Project::starter(); assert_eq!( destino.combinar_escena(&otro, 999_999).unwrap_err(), ErrorDoc::EscenaAusente(999_999) ); } #[test] fn los_sucesos_arrastran_su_ser_en_la_clausura_y_se_validan() { // Partimos del starter (tiene seres); dictamos en su escena un suceso // "aparece el primer ser". let mut p = Project::starter(); let ser_id = p.seres[0].id; let escena_id = p.escenas[0].id; p.escenas[0].sucesos.push(Suceso { t: 1.0, pos: [10.0, 2.0, 10.0], tipo: TipoSuceso::Aparece(ser_id), nota: "aquí aparece".into(), }); assert!(p.validar_referencias().is_empty(), "el suceso apunta a un ser válido"); // Extraer la escena arrastra ESE ser a la clausura (aunque el bioma no lo use). let frag = p.extraer_escena(escena_id).unwrap(); assert!(frag.seres.iter().any(|s| s.id == ser_id), "el ser del suceso viaja"); assert!(frag.validar_referencias().is_empty()); assert_eq!(frag.escenas[0].sucesos.len(), 1); // Un suceso que apunta a un ser inexistente se delata. let mut roto = Project::starter(); roto.escenas[0].sucesos.push(Suceso { t: 0.0, pos: [0.0; 3], tipo: TipoSuceso::Aparece(999_999), nota: String::new(), }); assert!(!roto.validar_referencias().is_empty()); } #[test] fn guardar_y_abrir_en_disco() { let dir = std::env::temp_dir().join(format!("voxeldoc-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); let path = dir.join("escena.voxstudio.ron"); let doc = Documento::nuevo(Project::starter()); doc.guardar(&path).unwrap(); let leido = Documento::abrir(&path).unwrap(); assert_eq!(leido.a_ron().unwrap(), doc.a_ron().unwrap()); let _ = std::fs::remove_file(&path); }