1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use std::cell::RefCell;
5use std::collections::HashSet;
6use std::sync::Arc;
7
8use crate::engine::ecs::component::{
9 LayoutComponent, RaycastableComponent, StyleComponent, TransformComponent,
10};
11use crate::engine::ecs::system::bounds_system::{BoundsSystem, RenderableBoundsMeasure};
12use crate::engine::ecs::system::editor_paint_system::PaintAssetTemplate;
13use crate::engine::ecs::{ComponentId, SignalEmitter, World};
14use crate::scripting::object::Value;
15use crate::scripting::runner::{LoadedMmsModule, MeowMeowRunner};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
18pub struct AssetModuleId(u32);
19
20#[derive(Debug)]
21pub struct AssetModule {
22 pub id: AssetModuleId,
23 pub path: PathBuf,
24 pub module: Arc<LoadedMmsModule>,
25}
26
27#[derive(Debug, Clone)]
28pub struct AssetItem {
29 pub module_id: AssetModuleId,
30 pub export_name: String,
31 pub title: String,
32 pub description: Option<String>,
33 pub category: Option<String>,
34 pub param_names: Vec<String>,
35}
36
37impl AssetItem {
38 pub fn asset_key(&self, module_path: &Path) -> String {
39 format!("{}::{}", module_path.display(), self.export_name)
40 }
41}
42
43#[derive(Debug, Clone)]
44pub enum AssetSource {
45 MmsModule {
46 module_id: AssetModuleId,
47 export_name: String,
48 },
49 RustFactory {
50 factory_name: String,
51 },
52}
53
54#[derive(Debug, Default)]
55pub struct AssetSystem {
56 modules: HashMap<AssetModuleId, AssetModule>,
57 module_paths: HashMap<PathBuf, AssetModuleId>,
58 pub items: Vec<AssetItem>,
59 next_module_id: u32,
60 asset_dir: Option<PathBuf>,
61 pending_remeasure: RefCell<Vec<(ComponentId, ComponentId)>>,
64}
65
66impl AssetSystem {
67 pub fn new() -> Self {
68 Self::default()
69 }
70
71 pub fn scan_assets_dir(&mut self, path: &Path) -> Result<(), String> {
72 self.asset_dir = Some(path.to_path_buf());
73 let entries = std::fs::read_dir(path)
74 .map_err(|e| format!("cannot read assets dir '{}': {e}", path.display()))?;
75
76 for entry in entries {
77 let entry = entry.map_err(|e| format!("cannot read assets dir entry: {e}"))?;
78 let path = entry.path();
79 if path.extension().and_then(|s| s.to_str()) == Some("mms") {
80 println!("[AssetSystem][debug] scanning asset module: {:?}", path);
81 self.load_module(path)?;
82 }
83 }
84
85 Ok(())
86 }
87
88 pub fn load_module(&mut self, path: PathBuf) -> Result<(), String> {
89 let normalized_path = path;
90 if self.module_paths.contains_key(&normalized_path) {
91 return Ok(());
92 }
93 let module = Arc::new(MeowMeowRunner::load_module_file(
94 normalized_path
95 .to_str()
96 .ok_or_else(|| format!("non-UTF8 asset path: {}", normalized_path.display()))?,
97 )?);
98
99 let module_id = AssetModuleId(self.next_module_id);
100 self.next_module_id += 1;
101
102 let file_stem = normalized_path
103 .file_stem()
104 .and_then(|stem| stem.to_str())
105 .unwrap_or("asset");
106
107 for (name, value) in &module.named_exports {
108 if let Value::Function { params, .. } = value {
109 println!(
110 "[AssetSystem][debug] found export function: {} (params: {:?})",
111 name, params
112 );
113
114 let title = if name == "main" || name == file_stem {
115 file_stem.to_string()
116 } else {
117 format!("{}: {}", file_stem, name)
118 };
119
120 self.items.push(AssetItem {
121 module_id,
122 export_name: name.clone(),
123 title,
124 description: None,
125 category: None,
126 param_names: params.clone(),
127 });
128 }
129 }
130 self.module_paths.insert(normalized_path.clone(), module_id);
131 self.modules.insert(
132 module_id,
133 AssetModule {
134 id: module_id,
135 path: normalized_path,
136 module,
137 },
138 );
139
140 Ok(())
141 }
142
143 pub fn get_item_module(&self, item: &AssetItem) -> Option<&LoadedMmsModule> {
144 self.modules
145 .get(&item.module_id)
146 .map(|module| module.module.as_ref())
147 }
148
149 pub fn asset_function(&self, item: &AssetItem) -> Option<&Value> {
150 self.get_item_module(item)
151 .and_then(|module| module.named_export(&item.export_name))
152 }
153
154 pub fn get_module_name(&self, module_id: AssetModuleId) -> Option<String> {
155 self.modules.get(&module_id).and_then(|m| {
156 m.path
157 .file_stem()
158 .and_then(|s| s.to_str())
159 .map(|s| s.to_string())
160 })
161 }
162
163 pub fn paint_templates(&self) -> Vec<PaintAssetTemplate> {
164 self.items
165 .iter()
166 .filter_map(|item| {
167 let module = self.modules.get(&item.module_id)?;
168 Some(PaintAssetTemplate {
169 key: item.asset_key(&module.path),
170 title: item.title.clone(),
171 module: Arc::clone(&module.module),
172 export_name: item.export_name.clone(),
173 param_names: item.param_names.clone(),
174 })
175 })
176 .collect()
177 }
178
179 pub fn build_asset_module_header(
180 &self,
181 world: &mut World,
182 emit: &mut dyn SignalEmitter,
183 module_name: &str,
184 ) -> Result<ComponentId, String> {
185 let asset_header_path = concat!(
186 env!("CARGO_MANIFEST_DIR"),
187 "/assets/components/asset_module_header.mms"
188 );
189
190 MeowMeowRunner::spawn_mms_module_component_from_file(
191 asset_header_path,
192 "asset_module_header",
193 vec![Value::String(module_name.to_string())],
194 None,
195 world,
196 emit,
197 )
198 }
199
200 pub fn spawn_asset_component(
201 &self,
202 item: &AssetItem,
203 args: Vec<Value>,
204 world: &mut World,
205 emit: &mut dyn SignalEmitter,
206 ) -> Result<ComponentId, String> {
207 let module = self.modules.get(&item.module_id).ok_or_else(|| {
208 format!(
209 "asset module not loaded for item '{}::{}'",
210 item.title, item.export_name
211 )
212 })?;
213
214 MeowMeowRunner::spawn_mms_module_component(
215 &module.module,
216 &item.export_name,
217 args,
218 None,
219 world,
220 emit,
221 )
222 }
223
224 pub fn spawn_asset_component_uninitialized(
225 &self,
226 item: &AssetItem,
227 args: Vec<Value>,
228 world: &mut World,
229 render_assets: &mut crate::engine::graphics::RenderAssets,
230 emit: &mut dyn SignalEmitter,
231 ) -> Result<ComponentId, String> {
232 let module = self.modules.get(&item.module_id).ok_or_else(|| {
233 format!(
234 "asset module not loaded for item '{}::{}'",
235 item.title, item.export_name
236 )
237 })?;
238
239 MeowMeowRunner::spawn_mms_module_component_uninitialized_with_assets(
240 &module.module,
241 &item.export_name,
242 args,
243 world,
244 Some(render_assets),
245 emit,
246 )
247 }
248
249 fn assets_panel_asset_path() -> &'static str {
250 concat!(env!("CARGO_MANIFEST_DIR"), "/assets/components/panels.mms")
251 }
252
253 pub fn spawn_assets_panel(
254 &self,
255 world: &mut World,
256 render_assets: &mut crate::engine::graphics::RenderAssets,
257 emit: &mut dyn SignalEmitter,
258 parent: ComponentId,
259 position: (f32, f32, f32),
260 ) -> Result<ComponentId, String> {
261 let panel_title = match self.asset_dir.as_ref() {
262 Some(path) => format!("Assets: {}", path.display()),
263 None => "Assets".to_string(),
264 };
265
266 let panel_root = MeowMeowRunner::spawn_mms_module_component_from_file(
267 Self::assets_panel_asset_path(),
268 "asset_panel",
269 vec![
270 Value::String(panel_title),
271 Value::Array(Vec::new()),
272 Value::Array(vec![
273 Value::Number(0.90),
274 Value::Number(1.00),
275 Value::Number(0.92),
276 Value::Number(1.0),
277 ]),
278 Value::Array(vec![
279 Value::Number(0.18),
280 Value::Number(0.78),
281 Value::Number(0.22),
282 Value::Number(0.95),
283 ]),
284 Value::Array(vec![
285 Value::Number(0.92),
286 Value::Number(0.97),
287 Value::Number(0.92),
288 Value::Number(1.0),
289 ]),
290 ],
291 None,
292 world,
293 emit,
294 )?;
295
296 let wrapper = world.add_component_boxed_named(
297 "assets_panel_shell",
298 Box::new(TransformComponent::new().with_position(position.0, position.1, position.2)),
299 );
300
301 world
302 .add_child(wrapper, panel_root)
303 .map_err(|e| format!("attach assets panel child failed: {e}"))?;
304
305 let content_root = world
306 .find_component(panel_root, "#assets_content_area")
307 .ok_or_else(|| "assets panel missing content area".to_string())?;
308
309 let mut last_module_id = None;
310 for (index, item) in self.items.iter().enumerate() {
311 if last_module_id != Some(item.module_id) {
312 last_module_id = Some(item.module_id);
313 if let Some(module_name) = self.get_module_name(item.module_id) {
314 let header_root = self.build_asset_module_header(world, emit, &module_name)?;
315 world
316 .add_child(content_root, header_root)
317 .map_err(|e| format!("attach asset header failed: {e}"))?;
318 }
319 }
320
321 let item_root = self.build_asset_item_shell(world, render_assets, emit, item, index)?;
322 world
323 .add_child(content_root, item_root)
324 .map_err(|e| format!("attach asset item failed: {e}"))?;
325 }
326
327 world.init_component_tree(wrapper, emit);
328
329 let mut cur = world.parent_of(content_root);
332 while let Some(ancestor) = cur {
333 if let Some(lc) = world.get_component_by_id_as_mut::<LayoutComponent>(ancestor) {
334 lc.dirty = true;
335 break;
336 }
337 cur = world.parent_of(ancestor);
338 }
339
340 emit.push_intent_now(
341 wrapper,
342 crate::engine::ecs::IntentValue::Attach {
343 parents: vec![parent],
344 child: wrapper,
345 },
346 );
347 Ok(wrapper)
348 }
349
350 fn subtree_needs_layout_root(world: &World, root: ComponentId) -> bool {
353 let mut stack = vec![root];
354 let mut visited = HashSet::new();
355
356 while let Some(node) = stack.pop() {
357 if !visited.insert(node) {
358 continue;
359 }
360
361 if world
362 .get_component_by_id_as::<StyleComponent>(node)
363 .is_some()
364 {
365 let mut has_layout = false;
366 let mut current = world.parent_of(node);
367 while let Some(ancestor) = current {
368 if world
369 .get_component_by_id_as::<LayoutComponent>(ancestor)
370 .is_some()
371 {
372 has_layout = true;
373 break;
374 }
375 current = world.parent_of(ancestor);
376 }
377 if !has_layout {
378 return true;
379 }
380 }
381
382 for &child in world.children_of(node) {
383 stack.push(child);
384 }
385 }
386
387 false
388 }
389
390 pub fn ensure_layout_root_if_needed(
393 world: &mut World,
394 root: ComponentId,
395 ) -> Option<ComponentId> {
396 if !Self::subtree_needs_layout_root(world, root) {
397 return None;
398 }
399
400 let layout_root = world
401 .add_component_boxed_named("preview_layout_root", Box::new(LayoutComponent::new(20.0)));
402
403 Some(layout_root)
404 }
405
406 fn disable_raycast_in_subtree(world: &mut World, root: ComponentId) {
410 let mut stack = vec![root];
411 let mut visited = HashSet::new();
412 while let Some(node) = stack.pop() {
413 if !visited.insert(node) {
414 continue;
415 }
416 if let Some(rc) = world.get_component_by_id_as_mut::<RaycastableComponent>(node) {
417 rc.enable = false;
418 }
419 for &child in world.children_of(node) {
420 stack.push(child);
421 }
422 }
423 }
424
425 fn remove_preview_placeholder(world: &mut World, item_root: ComponentId) {
426 if let Some(placeholder) = world.find_component(item_root, "#preview_placeholder") {
427 let _ = world.remove_component_subtree(placeholder);
428 }
429 }
430
431 pub fn build_asset_item_shell(
432 &self,
433 world: &mut World,
434 render_assets: &mut crate::engine::graphics::RenderAssets,
435 emit: &mut dyn SignalEmitter,
436 item: &AssetItem,
437 _index: usize,
438 ) -> Result<ComponentId, String> {
439 let asset_item_path = concat!(
440 env!("CARGO_MANIFEST_DIR"),
441 "/assets/components/asset_item.mms"
442 );
443 let module = self.modules.get(&item.module_id).ok_or_else(|| {
444 format!(
445 "asset module not loaded for item '{}::{}'",
446 item.title, item.export_name
447 )
448 })?;
449
450 let item_root = MeowMeowRunner::spawn_mms_module_component_from_file(
451 asset_item_path,
452 "asset_item",
453 vec![
454 Value::String(item.title.clone()),
455 Value::String(item.asset_key(&module.path)),
456 Value::Array(vec![
457 Value::Number(0.92),
458 Value::Number(0.97),
459 Value::Number(0.92),
460 Value::Number(1.0),
461 ]),
462 ],
463 None,
464 world,
465 emit,
466 )?;
467
468 let mut args = Vec::new();
470 for name in &item.param_names {
471 let lower_name = name.to_lowercase();
472 if lower_name.contains("color") {
473 args.push(Value::Array(vec![
474 Value::Number(0.5),
475 Value::Number(0.5),
476 Value::Number(0.5),
477 Value::Number(1.0),
478 ]));
479 } else if lower_name.contains("items") || lower_name.contains("sequence") {
480 args.push(Value::Array(Vec::new()));
481 } else if lower_name.contains("path")
482 || lower_name.contains("url")
483 || lower_name.contains("uri")
484 {
485 args.push(Value::String("assets/world/default.mms".to_string()));
486 } else if lower_name.contains("title")
487 || lower_name.contains("label")
488 || lower_name.contains("name")
489 || lower_name.contains("text")
490 {
491 args.push(Value::String("Preview".to_string()));
492 } else {
493 args.push(Value::Null);
494 }
495 }
496
497 match self.spawn_asset_component_uninitialized(item, args, world, render_assets, emit) {
498 Ok(preview_root) => {
499 Self::disable_raycast_in_subtree(world, preview_root);
502
503 let is_panel = self
506 .get_module_name(item.module_id)
507 .map(|name| name.contains("panel"))
508 .unwrap_or(false);
509 if is_panel {
510 } else {
512 let preview_slot = world
514 .find_component(item_root, "#preview_slot")
515 .unwrap_or(item_root);
516
517 let preview_shell = world.add_component_boxed_named(
518 "asset_preview_shell",
519 Box::new(TransformComponent::new().with_position(0.0, 0.0, 0.05)),
520 );
521
522 let bounds = BoundsSystem::measure_renderable_subtree_bounds(
523 world,
524 render_assets,
525 preview_root,
526 );
527
528 if let RenderableBoundsMeasure::Measured(b) = bounds {
529 let s = 0.2_f32 / b.max_dimension().max(1e-6);
530 let center = b.center();
531 emit.push_intent_now(
532 preview_shell,
533 crate::engine::ecs::IntentValue::UpdateTransform {
534 component_ids: vec![preview_shell],
535 translation: [
536 -center[0] * s,
537 -center[1] * s,
538 -center[2] * s + 0.05,
539 ],
540 rotation_quat_xyzw: [0.0, 0.0, 0.0, 1.0],
541 scale: [s, s, s],
542 },
543 );
544 } else {
545 emit.push_intent_now(
546 preview_shell,
547 crate::engine::ecs::IntentValue::UpdateTransform {
548 component_ids: vec![preview_shell],
549 translation: [0.0, 0.0, 0.05],
550 rotation_quat_xyzw: [0.0, 0.0, 0.0, 1.0],
551 scale: [0.5, 0.5, 0.5],
552 },
553 );
554 }
555
556 world
557 .add_child(preview_shell, preview_root)
558 .map_err(|e| format!("attach preview failed: {e}"))?;
559 world
560 .add_child(preview_slot, preview_shell)
561 .map_err(|e| format!("attach preview shell failed: {e}"))?;
562 Self::remove_preview_placeholder(world, item_root);
563 }
564 }
565 Err(e) => {
566 eprintln!(
567 "[AssetSystem] preview unavailable for '{}::{}': {e}",
568 item.title, item.export_name
569 );
570 }
571 }
572
573 Ok(item_root)
574 }
575
576 pub fn remeasure_pending_previews(
581 &mut self,
582 world: &mut World,
583 render_assets: &crate::engine::graphics::RenderAssets,
584 emit: &mut dyn SignalEmitter,
585 ) {
586 let pending = self.pending_remeasure.replace(Vec::new());
587 for (preview_shell, preview_root) in pending {
588 let bounds =
589 BoundsSystem::calculate_subtree_local_bounds(world, render_assets, preview_root);
590
591 if let Some(b) = bounds {
592 let target_width_gu = 0.2_f32;
594 let current_width = b.width().max(1e-6);
595 let s = target_width_gu / current_width;
596 let center = b.center();
597
598 emit.push_intent_now(
599 preview_shell,
600 crate::engine::ecs::IntentValue::UpdateTransform {
601 component_ids: vec![preview_shell],
602 translation: [-center[0] * s, -center[1] * s, -center[2] * s + 0.05],
603 rotation_quat_xyzw: [0.0, 0.0, 0.0, 1.0],
604 scale: [s, s, s],
605 },
606 );
607 } else {
608 eprintln!(
610 "[AssetSystem] remeasure: still no bounds for preview shell {:?}",
611 preview_shell,
612 );
613 }
614 }
615 }
616}
617
618#[cfg(test)]
619mod tests {
620 use super::*;
621 use crate::engine::ecs::World;
622 use crate::engine::ecs::command_queue::CommandQueue;
623 use crate::engine::ecs::component::TextComponent;
624 use crate::engine::graphics::RenderAssets;
625 use std::path::PathBuf;
626 use std::time::{SystemTime, UNIX_EPOCH};
627
628 fn temp_asset_directory() -> PathBuf {
629 let now = SystemTime::now()
630 .duration_since(UNIX_EPOCH)
631 .expect("time went backwards")
632 .as_nanos();
633 let tmp_dir = std::env::temp_dir().join(format!("mittens_engine_assets_{}", now));
634 std::fs::create_dir_all(&tmp_dir).expect("create temp dir");
635 tmp_dir
636 }
637
638 fn first_text_under(world: &World, root: ComponentId) -> Option<String> {
639 if let Some(text) = world.get_component_by_id_as::<TextComponent>(root) {
640 return Some(text.text.clone());
641 }
642
643 for child in world.children_of(root) {
644 if let Some(text) = first_text_under(world, *child) {
645 return Some(text);
646 }
647 }
648
649 None
650 }
651
652 #[test]
653 fn scan_assets_dir_loads_mms_function_exports() {
654 let tmp_dir = temp_asset_directory();
655 let asset_path = tmp_dir.join("test_asset.mms");
656 std::fs::write(
657 &asset_path,
658 r#"
659 export fn example() {
660 let root = T {}
661 return root
662 }
663 "#,
664 )
665 .expect("write asset file");
666
667 let mut system = AssetSystem::new();
668 system.scan_assets_dir(&tmp_dir).expect("scan assets dir");
669
670 assert_eq!(system.items.len(), 1);
671 assert_eq!(system.items[0].export_name, "example");
672 assert!(system.items[0].title.contains("test_asset::example"));
673 }
674
675 #[test]
676 fn load_module_can_spawn_component() {
677 let tmp_dir = temp_asset_directory();
678 let asset_path = tmp_dir.join("test_asset.mms");
679 std::fs::write(
680 &asset_path,
681 r#"
682 export fn example() {
683 let root = T {}
684 return root
685 }
686 "#,
687 )
688 .expect("write asset file");
689
690 let mut system = AssetSystem::new();
691 system.load_module(asset_path.clone()).expect("load module");
692
693 let item = &system.items[0];
694 let mut world = World::default();
695 let mut render_assets = RenderAssets::new();
696 let mut emit = CommandQueue::new();
697 let _id = system
698 .spawn_asset_component(item, vec![], &mut world, &mut emit)
699 .expect("spawn component");
700 }
701
702 #[test]
703 fn build_asset_item_shell_uses_asset_title_as_label() {
704 let tmp_dir = temp_asset_directory();
705 let asset_path = tmp_dir.join("test_asset.mms");
706 std::fs::write(
707 &asset_path,
708 r#"
709 export fn example() {
710 return T {}
711 }
712 "#,
713 )
714 .expect("write asset file");
715
716 let mut system = AssetSystem::new();
717 system.load_module(asset_path).expect("load module");
718
719 let item = &system.items[0];
720 let mut world = World::default();
721 let mut render_assets = RenderAssets::new();
722 let mut emit = CommandQueue::new();
723 let item_root = system
724 .build_asset_item_shell(&mut world, &mut render_assets, &mut emit, item, 0)
725 .expect("build asset item");
726
727 assert_eq!(
728 first_text_under(&world, item_root),
729 Some("test_asset::example".to_string())
730 );
731 }
732
733 #[test]
734 fn build_asset_item_shell_keeps_item_when_preview_fails() {
735 let tmp_dir = temp_asset_directory();
736 let asset_path = tmp_dir.join("bad_preview.mms");
737 std::fs::write(
738 &asset_path,
739 r#"
740 export fn broken_preview() {
741 return null
742 }
743 "#,
744 )
745 .expect("write asset file");
746
747 let mut system = AssetSystem::new();
748 system.load_module(asset_path).expect("load module");
749
750 let item = &system.items[0];
751 let mut world = World::default();
752 let mut render_assets = RenderAssets::new();
753 let mut emit = CommandQueue::new();
754 let item_root = system
755 .build_asset_item_shell(&mut world, &mut render_assets, &mut emit, item, 0)
756 .expect("build asset item despite preview failure");
757
758 assert_eq!(world.component_label(item_root), Some("asset_item"));
759 assert_eq!(
760 first_text_under(&world, item_root),
761 Some("bad_preview::broken_preview".to_string())
762 );
763 }
764
765 #[test]
766 fn spawn_asset_component_uninitialized_supports_procedural_renderables() {
767 let tmp_dir = temp_asset_directory();
768 let asset_path = tmp_dir.join("primitives_preview.mms");
769 std::fs::write(
770 &asset_path,
771 r#"
772 export fn heart_preview() {
773 return T {
774 R.heart() {}
775 }
776 }
777 "#,
778 )
779 .expect("write asset file");
780
781 let mut system = AssetSystem::new();
782 system.load_module(asset_path).expect("load module");
783
784 let item = &system.items[0];
785 let mut world = World::default();
786 let mut render_assets = RenderAssets::new();
787 let mut emit = CommandQueue::new();
788 let root = system
789 .spawn_asset_component_uninitialized(
790 item,
791 vec![],
792 &mut world,
793 &mut render_assets,
794 &mut emit,
795 )
796 .expect("spawn procedural preview");
797
798 assert!(world.get_component_record(root).is_some());
799 }
800
801 #[test]
802 fn spawn_assets_panel_shows_assets_dir_in_title() {
803 let tmp_dir = temp_asset_directory();
804 let asset_path = tmp_dir.join("test_asset.mms");
805 std::fs::write(
806 &asset_path,
807 r#"
808 export fn example() {
809 let root = T {}
810 return root
811 }
812 "#,
813 )
814 .expect("write asset file");
815
816 let mut system = AssetSystem::new();
817 system.scan_assets_dir(&tmp_dir).expect("scan assets dir");
818
819 let mut world = World::default();
820 let mut render_assets = RenderAssets::new();
821 let mut emit = CommandQueue::new();
822 let parent = world.add_component_boxed_named("parent", Box::new(TransformComponent::new()));
823
824 let wrapper = system
825 .spawn_assets_panel(
826 &mut world,
827 &mut render_assets,
828 &mut emit,
829 parent,
830 (0.0, 0.0, 0.0),
831 )
832 .expect("spawn assets panel");
833
834 let title_bar = world
835 .find_component(wrapper, "#title_bar")
836 .expect("expected title bar component");
837
838 let title_position_transform = world
839 .children_of(title_bar)
840 .iter()
841 .copied()
842 .find(|&child| {
843 world
844 .get_component_record(child)
845 .map(|node| node.component_type == "transform")
846 .unwrap_or(false)
847 })
848 .expect("expected title position transform child");
849
850 let title_text = world
851 .children_of(title_position_transform)
852 .iter()
853 .copied()
854 .find(|&child| {
855 world
856 .get_component_record(child)
857 .map(|node| node.component_type == "text")
858 .unwrap_or(false)
859 })
860 .expect("expected title text component");
861
862 let text_value = world
863 .get_component_by_id_as::<crate::engine::ecs::component::TextComponent>(title_text)
864 .expect("expected text component")
865 .text
866 .clone();
867
868 assert_eq!(text_value, format!("Assets: {}", tmp_dir.display()));
869 }
870}