tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
1// =============================================================================
2// llimphi-voxel :: tests/documento — abrir, guardar, combinar, extraer
3// -----------------------------------------------------------------------------
4// Certifica la administración por archivos: round-trip del format (RON y
5// postcard), combinar proyectos sin colisiones ni referencias rotas, y extraer
6// una escena con toda su clausura de dependencias como fragmento portable.
7// (`Project` no es `PartialEq` por historia, así que se compara por
8// serialización — el round-trip idempotente es prueba suficiente.)
9// =============================================================================
10
11use llimphi_voxel::{Documento, ErrorDoc, Project, Suceso, TipoSuceso, MAGIA_DOC, VERSION_DOC};
12
13#[test]
14fn round_trip_ron_preserva_el_proyecto() {
15 let p = Project::starter();
16 let doc = Documento::nuevo(p.clone());
17 let texto = doc.a_ron().unwrap();
18 let recuperado = Documento::de_ron(&texto).unwrap();
19 // Re-serializar da el MISMO texto: el proyecto sobrevivió íntegro.
20 assert_eq!(recuperado.a_ron().unwrap(), texto);
21 assert_eq!(recuperado.magia, MAGIA_DOC);
22 assert_eq!(recuperado.version, VERSION_DOC);
23 assert_eq!(recuperado.proyecto.escenas.len(), p.escenas.len());
24}
25
26#[test]
27fn round_trip_postcard_nativo() {
28 let doc = Documento::nuevo(Project::starter());
29 let bytes = doc.a_bytes().unwrap();
30 let recuperado = Documento::de_bytes(&bytes).unwrap();
31 // El format nativo compacto también preserva el proyecto (comparado vía RON).
32 assert_eq!(recuperado.a_ron().unwrap(), doc.a_ron().unwrap());
33 // Y es más chico que el RON legible.
34 assert!(bytes.len() < doc.a_ron().unwrap().len());
35}
36
37#[test]
38fn un_ron_ajeno_se_rechaza() {
39 // Un RON que no parsea como documento se rechaza (no se malinterpreta).
40 assert!(matches!(
41 Documento::de_ron("(foo: 1, bar: 2)"),
42 Err(ErrorDoc::Parsear(_))
43 ));
44}
45
46#[test]
47fn marca_ajena_pero_ron_valido_se_detecta() {
48 let doc = Documento::nuevo(Project::starter());
49 let texto = doc.a_ron().unwrap().replace(MAGIA_DOC, "otra-cosa");
50 assert_eq!(Documento::de_ron(&texto).unwrap_err(), ErrorDoc::MagiaAjena);
51}
52
53#[test]
54fn version_futura_se_rechaza() {
55 let mut doc = Documento::nuevo(Project::starter());
56 doc.version = VERSION_DOC + 1;
57 let texto = doc.a_ron().unwrap();
58 assert_eq!(
59 Documento::de_ron(&texto).unwrap_err(),
60 ErrorDoc::VersionFutura(VERSION_DOC + 1)
61 );
62}
63
64#[test]
65fn el_starter_es_integro() {
66 // El proyecto de arranque no tiene referencias rotas.
67 assert!(Project::starter().validar_referencias().is_empty());
68}
69
70#[test]
71fn combinar_no_colisiona_ni_rompe_referencias() {
72 let mut a = Project::starter();
73 let b = Project::starter();
74 let (leyes_a, mats_a, seres_a, biomas_a, mundos_a, escenas_a) = (
75 a.leyes.len(),
76 a.materiales.len(),
77 a.seres.len(),
78 a.biomas.len(),
79 a.mundos.len(),
80 a.escenas.len(),
81 );
82
83 a.combinar(&b);
84
85 // Se duplicó todo (dos starters).
86 assert_eq!(a.leyes.len(), leyes_a + b.leyes.len());
87 assert_eq!(a.materiales.len(), mats_a + b.materiales.len());
88 assert_eq!(a.seres.len(), seres_a + b.seres.len());
89 assert_eq!(a.biomas.len(), biomas_a + b.biomas.len());
90 assert_eq!(a.mundos.len(), mundos_a + b.mundos.len());
91 assert_eq!(a.escenas.len(), escenas_a + b.escenas.len());
92
93 // Ningún id repetido en todo el proyecto combinado.
94 let mut ids: Vec<u64> = a
95 .leyes
96 .iter()
97 .map(|x| x.id)
98 .chain(a.materiales.iter().map(|x| x.id))
99 .chain(a.seres.iter().map(|x| x.id))
100 .chain(a.biomas.iter().map(|x| x.id))
101 .chain(a.mundos.iter().map(|x| x.id))
102 .chain(a.escenas.iter().map(|x| x.id))
103 .collect();
104 let total = ids.len();
105 ids.sort_unstable();
106 ids.dedup();
107 assert_eq!(ids.len(), total, "hay ids repetidos tras combinar");
108
109 // Y TODAS las referencias cruzadas siguen resolviendo.
110 let rotas = a.validar_referencias();
111 assert!(rotas.is_empty(), "combinar rompió referencias: {rotas:?}");
112}
113
114#[test]
115fn extraer_escena_produce_un_fragmento_integro_y_minimo() {
116 let p = Project::starter();
117 let escena_id = p.escenas[0].id;
118 let frag = p.extraer_escena(escena_id).expect("la escena existe");
119
120 // El fragmento tiene exactamente una escena y es íntegro.
121 assert_eq!(frag.escenas.len(), 1);
122 assert_eq!(frag.escenas[0].id, escena_id);
123 let rotas = frag.validar_referencias();
124 assert!(rotas.is_empty(), "fragmento roto: {rotas:?}");
125
126 // Es MÍNIMO: no arrastra el mundo/bioma que la escena no usa. El starter
127 // tiene 2 mundos; la escena demo usa uno → el fragmento trae 1.
128 assert_eq!(frag.mundos.len(), 1);
129 assert!(frag.mundos.len() < p.mundos.len());
130 assert_eq!(frag.mundos[0].id, p.escenas[0].mundo);
131}
132
133#[test]
134fn extraer_luego_combinar_reconstituye_una_escena_en_otro_proyecto() {
135 let origen = Project::starter();
136 let escena_id = origen.escenas[0].id;
137
138 // Un proyecto destino vacío recibe SÓLO esa escena, con su clausura.
139 let mut destino = Project { next_id: 1, ..Default::default() };
140 destino.combinar_escena(&origen, escena_id).unwrap();
141
142 assert_eq!(destino.escenas.len(), 1);
143 assert!(destino.validar_referencias().is_empty());
144 // El destino quedó auto-contenido: resuelve el mundo de la escena.
145 let esc = &destino.escenas[0];
146 assert!(destino.mundo(esc.mundo).is_some());
147 // Y los actores apuntan a seres válidos del destino.
148 for a in &esc.actors {
149 assert!(destino.seres.get(a.character).is_some());
150 }
151}
152
153#[test]
154fn combinar_escena_inexistente_es_error() {
155 let mut destino = Project::starter();
156 let otro = Project::starter();
157 assert_eq!(
158 destino.combinar_escena(&otro, 999_999).unwrap_err(),
159 ErrorDoc::EscenaAusente(999_999)
160 );
161}
162
163#[test]
164fn los_sucesos_arrastran_su_ser_en_la_clausura_y_se_validan() {
165 // Partimos del starter (tiene seres); dictamos en su escena un suceso
166 // "aparece el primer ser".
167 let mut p = Project::starter();
168 let ser_id = p.seres[0].id;
169 let escena_id = p.escenas[0].id;
170 p.escenas[0].sucesos.push(Suceso {
171 t: 1.0,
172 pos: [10.0, 2.0, 10.0],
173 tipo: TipoSuceso::Aparece(ser_id),
174 nota: "aquí aparece".into(),
175 });
176 assert!(p.validar_referencias().is_empty(), "el suceso apunta a un ser válido");
177
178 // Extraer la escena arrastra ESE ser a la clausura (aunque el bioma no lo use).
179 let frag = p.extraer_escena(escena_id).unwrap();
180 assert!(frag.seres.iter().any(|s| s.id == ser_id), "el ser del suceso viaja");
181 assert!(frag.validar_referencias().is_empty());
182 assert_eq!(frag.escenas[0].sucesos.len(), 1);
183
184 // Un suceso que apunta a un ser inexistente se delata.
185 let mut roto = Project::starter();
186 roto.escenas[0].sucesos.push(Suceso {
187 t: 0.0,
188 pos: [0.0; 3],
189 tipo: TipoSuceso::Aparece(999_999),
190 nota: String::new(),
191 });
192 assert!(!roto.validar_referencias().is_empty());
193}
194
195#[test]
196fn guardar_y_abrir_en_disco() {
197 let dir = std::env::temp_dir().join(format!("voxeldoc-{}", std::process::id()));
198 std::fs::create_dir_all(&dir).unwrap();
199 let path = dir.join("escena.voxstudio.ron");
200
201 let doc = Documento::nuevo(Project::starter());
202 doc.guardar(&path).unwrap();
203 let leido = Documento::abrir(&path).unwrap();
204 assert_eq!(leido.a_ron().unwrap(), doc.a_ron().unwrap());
205
206 let _ = std::fs::remove_file(&path);
207}