Снимок Derma пересобирается по dirty-флагу (ввод, таймеры, сеттеры), а не раз в 6 кадров: в покое 0.26-0.5 мс вместо 4-6 мс, без пиков 47-84 мс. Невидимые поддеревья и обрезанные команды не попадают в снимок; сортировка детей без ZPos не аллоцирует; hover не перезапускает раскладку. Меню прогревается на загрузке карты. Слайдеры: загружается dnumberscratch.lua, добавлены NoClipping/GetCaretPos/SetCaretPos/input.SetCursorPos, OnCursorMoved получает локальные координаты, MouseCapture удерживает перетаскивание; есть fallback DSlider/DNumSlider/DNumberScratch. Колесо всплывает от иконки/кнопки до DScrollPanel/DTree; починен корень DTree (высота ChildNodes до VBar). Клик/колесо проверяются тестами и отладочными SOURCE_RS_UI_CLICK/WHEEL. Текст: метрики surface.GetTextSize совпадают с bitmap-рендером, вертикаль по высоте глифа, ContentAlignment как в GMod, вкладки центрируются, дерево слева с отступом, заглушки иконок - две строки по центру с обрезкой. Иконки: очередь видимых моделей по площади, до 8/~8 мс за кадр, LRU-кэш переиспользуется.
462 lines
17 KiB
Rust
462 lines
17 KiB
Rust
//! Настоящий 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<PathBuf> {
|
||
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<LuaRuntime> {
|
||
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<String> {
|
||
let mut texts: Vec<String> = 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}"
|
||
);
|
||
}
|