gmod-rs/crates/lua/tests/ui.rs
Niko Marmeladkov 1b24f280d3 Q-меню: dirty-снимок, слайдеры, колесо и выравнивание текста
Снимок 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-кэш переиспользуется.
2026-10-24 20:37:18 +03:00

462 lines
17 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Настоящий 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}"
);
}