//! Настоящий UI Sandbox: Derma, VGUI и spawnmenu из установки Garry's Mod. //! //! Без ассетов тесты пропускаются: путь задаётся `SOURCE_RS_ASSETS` или //! `~/gmod_server/server/garrysmod`. use glua::{LuaRuntime, UiCommand, UiKind}; use std::path::PathBuf; fn game_dir() -> Option { let root = std::env::var_os("SOURCE_RS_ASSETS") .map(PathBuf::from) .or_else(|| { std::env::var_os("HOME").map(|home| PathBuf::from(home).join("gmod_server/server")) })?; let game = root.join("garrysmod"); game.is_dir().then_some(game) } fn loaded_ui() -> Option { let Some(game) = game_dir() else { eprintln!("пропуск: ассеты Garry's Mod не найдены"); return None; }; let runtime = LuaRuntime::new().expect("рантайм"); runtime.set_file_root(&game).expect("корень игры"); let (_, _, failed) = runtime.load_scripts(&game); assert_eq!(failed, 0, "оружие и энтити должны грузиться"); let report = runtime.load_ui(&game); eprintln!("UI: загружено {} файлов", report.loaded.len()); for error in &report.errors { eprintln!("UI: ошибка: {error}"); } // Меню обязано пережить отсутствующие контролы: ошибки отдельных // файлов допустимы, но SpawnMenu и модуль spawnmenu должны создаться. assert_eq!( runtime .probe("return tostring(IsValid(g_SpawnMenu))") .expect("probe") .as_deref(), Some("true"), "g_SpawnMenu не создан" ); assert_eq!( runtime .probe("return tostring(spawnmenu.GetCreationTabs() ~= nil)") .expect("probe") .as_deref(), Some("true"), "модуль spawnmenu не загружен" ); Some(runtime) } fn texts(commands: &[UiCommand]) -> Vec { let mut texts: Vec = commands .iter() .filter_map(|command| command.text.clone()) .collect(); texts.sort(); texts.dedup(); texts } fn dump(label: &str, commands: &[UiCommand]) { eprintln!("[{label}] команд: {}", commands.len()); eprintln!( "[{label}] rect: {}", commands .iter() .filter(|command| command.kind == UiKind::Rect) .count() ); for text in texts(commands).iter().take(30) { eprintln!("[{label}] текст: {text}"); } } #[test] fn ui_spawnmenu_snapshot_and_click() { let Some(runtime) = loaded_ui() else { return; }; runtime.ui_set_screen(1280, 720); assert!(!runtime.ui_visible()); assert!(runtime.ui_snapshot().is_empty()); runtime.ui_toggle(true); assert!(runtime.ui_visible()); // Первый снимок прокачивает `timer.Simple(0, ...)`, которые создают // вкладки и содержимое спавн-меню; второй — уже для проверки. let _ = runtime.ui_snapshot(); // Ветки DTree раскрываются анимацией 0.3 с (как в GMod). Даём ей // завершиться и делаем ещё два снимка: первый завершает анимацию и // заказывает раскладку, второй уже видит подписи веток. std::thread::sleep(std::time::Duration::from_millis(400)); let _ = runtime.ui_snapshot(); let first = runtime.ui_snapshot(); dump("первый", &first); assert!( first.len() > 20, "меню должно нарисовать больше 20 команд, получили {}", first.len() ); let first_texts = texts(&first); for expected in ["Weapons", "Entities", "Spawnlists", "Browse"] { assert!( first_texts.iter().any(|text| text == expected), "в снимке нет «{expected}»: {first_texts:?}" ); } // Категории из settings/spawnlist_default/*.txt доходят до Lua и до // дерева спавн-меню. assert!( first_texts.iter().any(|text| text == "Construction Props"), "нет категории Construction Props: {first_texts:?}" ); let images: Vec<&str> = first .iter() .filter(|command| command.kind == UiKind::Image) .filter_map(|command| command.model.as_deref()) .collect(); assert!( images .iter() .any(|model| model.starts_with("models/") && model.ends_with(".mdl")), "нет ни одной 3D-иконки модели: images={images:?}" ); assert!( first .iter() .filter(|command| command.kind == UiKind::Image) .any(|command| command.clip.is_some()), "иконки должны обрезаться по границам панели" ); // Клик по вкладке Weapons переключает активную панель: снимок обязан // измениться. let tab = first .iter() .find(|command| command.text.as_deref() == Some("Weapons")) .expect("вкладка Weapons"); let x = tab.x + tab.w * 0.5; let y = tab.y + tab.h * 0.5; eprintln!("клик по Weapons в ({x}, {y})"); runtime.ui_mouse("move", x, y, 1); runtime.ui_mouse("down", x, y, 1); runtime.ui_mouse("up", x, y, 1); let second = runtime.ui_snapshot(); dump("после клика", &second); assert_ne!(first, second, "клик по вкладке должен менять снимок"); assert_eq!( runtime .probe("return tostring(g_SpawnMenu.CreateMenu:GetActiveTab():GetText())") .expect("probe") .as_deref(), Some("#spawnmenu.category.weapons"), "активная вкладка не переключилась" ); runtime.ui_toggle(false); assert!(!runtime.ui_visible()); assert!(runtime.ui_snapshot().is_empty()); } #[test] fn ui_snapshot_has_a_bounded_command_count() { let Some(runtime) = loaded_ui() else { return; }; runtime.ui_toggle(true); let commands = runtime.ui_snapshot(); assert!( commands.len() <= glua::UI_COMMAND_LIMIT, "лимит команд: {}", commands.len() ); assert!(commands.len() > 20, "меню пустое: {}", commands.len()); } /// Настоящие DSlider/DNumSlider/DNumberScratch из `lua/vgui` загружены и /// работают: значение меняется от перетаскивания, текст числа виден. #[test] fn ui_num_slider_drags_and_updates_value() { let Some(runtime) = loaded_ui() else { return; }; runtime.ui_set_screen(1280, 720); runtime .run( "slider_test.lua", br#" rawset( _G, "g_SpawnMenu", nil ) local p = vgui.Create( "Panel" ) p:SetPos( 100, 100 ) p:SetSize( 300, 60 ) local slider = vgui.Create( "DNumSlider", p ) slider:SetPos( 10, 10 ) slider:SetSize( 220, 32 ) slider:SetMin( 0 ) slider:SetMax( 10 ) slider:SetDecimals( 1 ) slider:SetValue( 5 ) _G.__test_slider = slider __sr_ui.root = p __sr_ui.visible = true __sr_ui.mark_dirty( true ) "#, ) .expect("слайдер создан"); let commands = runtime.ui_snapshot(); assert!( commands .iter() .any(|command| command.text.as_deref() == Some("5.0")), "DNumSlider не нарисовал число: {:?}", texts(&commands) ); let before = runtime .probe("return tostring(__test_slider:GetValue())") .expect("probe") .unwrap_or_default(); assert_eq!(before, "5", "начальное значение"); // Тянем ползунок вправо: значение должно вырасти. let y = 126.0; runtime.ui_mouse("move", 230.0, y, 0); runtime.ui_mouse("down", 230.0, y, 1); runtime.ui_mouse("move", 270.0, y, 0); runtime.ui_mouse("up", 270.0, y, 1); let _ = runtime.ui_snapshot(); let after: f32 = runtime .probe("return tostring(__test_slider:GetValue())") .expect("probe") .unwrap_or_default() .parse() .unwrap_or(0.0); assert!(after > 6.0, "перетаскивание не изменило значение: {after}"); } /// Колесо над содержимым DScrollPanel прокручивает канвас (событие /// всплывает от иконки/кнопки до панели со скроллбаром). #[test] fn ui_wheel_scrolls_scroll_panel() { let Some(runtime) = loaded_ui() else { return; }; runtime.ui_set_screen(1280, 720); runtime .run( "scroll_test.lua", br#" rawset( _G, "g_SpawnMenu", nil ) local p = vgui.Create( "DScrollPanel" ) p:SetPos( 50, 50 ) p:SetSize( 300, 200 ) local canvas = p:GetCanvas() local item = vgui.Create( "DButton", canvas ) item:SetPos( 0, 0 ) item:SetSize( 120, 800 ) item:SetText( "long" ) _G.__test_scroll = p __sr_ui.root = p __sr_ui.visible = true __sr_ui.mark_dirty( true ) "#, ) .expect("скролл-панель создана"); let _ = runtime.ui_snapshot(); let enabled = runtime .probe("return tostring(__test_scroll.VBar.Enabled)") .expect("probe") .unwrap_or_default(); assert_eq!(enabled, "true", "скроллбар не включился"); runtime.ui_mouse("wheel", 100.0, 100.0, -1); let _ = runtime.ui_snapshot(); let offset = runtime .probe("return tostring(__test_scroll:GetCanvas().y)") .expect("probe") .unwrap_or_default(); let offset: f32 = offset.parse().unwrap_or(0.0); assert!(offset < -10.0, "колесо не прокрутило канвас: y={offset}"); } /// Колесо над узлом DTree прокручивает дерево: событие всплывает от кнопки /// узла до DScrollPanel, а не теряется в базовой заглушке. #[test] fn ui_wheel_scrolls_tree() { let Some(runtime) = loaded_ui() else { return; }; runtime.ui_set_screen(1280, 720); runtime .run( "tree_scroll_test.lua", br#" rawset( _G, "g_SpawnMenu", nil ) local tree = vgui.Create( "DTree" ) tree:SetPos( 20, 20 ) tree:SetSize( 200, 160 ) for i = 1, 40 do tree:AddNode( "Node " .. i ) end tree:Root():SetExpanded( true, true ) _G.__test_tree = tree __sr_ui.root = tree __sr_ui.visible = true __sr_ui.mark_dirty( true ) "#, ) .expect("дерево создано"); let _ = runtime.ui_snapshot(); let _ = runtime.ui_snapshot(); runtime.ui_mouse("wheel", 100.0, 60.0, -1); let _ = runtime.ui_snapshot(); let offset = runtime .probe("return tostring(__test_tree:GetCanvas().y)") .expect("probe") .unwrap_or_default(); let offset: f32 = offset.parse().unwrap_or(0.0); assert!(offset < -10.0, "колесо не прокрутило дерево: y={offset}"); } /// Клик по заголовку DCollapsibleCategory сворачивает/разворачивает /// категорию: по этому пути открываются панели инструментов со слайдерами. #[test] fn ui_collapsible_category_toggles() { let Some(runtime) = loaded_ui() else { return; }; runtime.ui_set_screen(1280, 720); runtime .run( "category_test.lua", br#" rawset( _G, "g_SpawnMenu", nil ) local list = vgui.Create( "DCategoryList" ) list:SetPos( 20, 20 ) list:SetSize( 220, 300 ) local category = list:Add( "Constraints" ) category:Add( "DButton" ):SetText( "Slider" ) _G.__test_category = category __sr_ui.root = list __sr_ui.visible = true __sr_ui.mark_dirty( true ) "#, ) .expect("категория создана"); let _ = runtime.ui_snapshot(); let expanded = runtime .probe("return tostring(__test_category:GetExpanded())") .expect("probe") .unwrap_or_default(); assert_eq!(expanded, "true", "категория должна быть раскрыта"); runtime.ui_mouse("move", 60.0, 28.0, 0); runtime.ui_mouse("down", 60.0, 28.0, 1); runtime.ui_mouse("up", 60.0, 28.0, 1); let _ = runtime.ui_snapshot(); let expanded = runtime .probe("return tostring(__test_category:GetExpanded())") .expect("probe") .unwrap_or_default(); assert_eq!(expanded, "false", "клик по заголовку не свернул категорию"); } /// Текст с ContentAlignment 5 (центр) действительно центрируется, а не /// уезжает по старой таблице выравниваний. #[test] fn ui_label_alignment_centers_text() { let Some(runtime) = loaded_ui() else { return; }; runtime.ui_set_screen(1280, 720); runtime .run( "align_test.lua", br#" rawset( _G, "g_SpawnMenu", nil ) local label = vgui.Create( "DLabel" ) label:SetPos( 0, 0 ) label:SetSize( 200, 20 ) label:SetContentAlignment( 5 ) label:SetText( "center" ) __sr_ui.root = label __sr_ui.visible = true __sr_ui.mark_dirty( true ) "#, ) .expect("метка создана"); let commands = runtime.ui_snapshot(); let text = commands .iter() .find(|command| command.text.as_deref() == Some("center")) .expect("текст метки"); let expected = (200.0 - text.w) * 0.5; assert!( (text.x - expected).abs() < 0.6, "x={} ожидали {} (w={})", text.x, expected, text.w ); } /// Снимок заказывается по dirty-флагу: после снимка флаг снят, сеттер /// панели снова его поднимает. #[test] fn ui_snapshot_dirty_flag_round_trip() { let Some(runtime) = loaded_ui() else { return; }; runtime .run( "dirty_test.lua", br#" rawset( _G, "g_SpawnMenu", nil ) local p = vgui.Create( "Panel" ) p:SetSize( 100, 100 ) __sr_ui.root = p __sr_ui.visible = true __sr_ui.mark_dirty( true ) "#, ) .expect("панель создана"); assert!(runtime.ui_dirty(), "новое меню должно быть грязным"); let _ = runtime.ui_snapshot(); assert!(!runtime.ui_dirty(), "после снимка флаг должен сняться"); runtime .run("dirty_test_set.lua", br#"__sr_ui.root:SetSize( 120, 100 )"#) .expect("сеттер"); assert!(runtime.ui_dirty(), "SetSize должен поднять dirty-флаг"); } /// Категории спавн-меню (settings/spawnlist_default/*.txt) реально читаются /// Lua-скриптом через `file.Find`; иначе вкладка Spawnlists осталась бы пустой. #[test] fn ui_spawnlist_files_are_visible_to_lua() { let Some(game) = game_dir() else { return; }; let runtime = LuaRuntime::new().expect("рантайм"); runtime.set_file_root(&game).expect("корень"); let files = runtime .probe( r#" local files = file.Find("settings/spawnlist_default/*.txt", "GAME") return tostring(#files) .. ":" .. tostring(files[1]) "#, ) .expect("probe") .unwrap_or_default(); eprintln!("spawnlist: {files}"); let count: usize = files .split(':') .next() .and_then(|value| value.parse().ok()) .unwrap_or(0); assert!(count >= 5, "не найдены spawnlist-файлы: {files}"); assert!( files.contains(".txt"), "file.Find вернул не имена файлов: {files}" ); }