gmod-rs/crates/lua/tests/include.rs
Niko Marmeladkov ffd9e736bc
Some checks are pending
CI / checks (push) Waiting to run
Derma: настоящее Q-меню Sandbox на Lua поверх VGUI-слоя
В crates/lua добавлен минимальный слой VGUI/Derma: панели-таблицы,
vgui.Register/Create, surface/draw с записью команд, ввод и снимок кадра
(до 4096 команд, глубина 32). На нём грузятся настоящие lua/derma,
lua/skins/default.lua, lua/vgui/* и lua/includes/modules/spawnmenu.lua,
затем Sandbox и OnGamemodeLoaded строят g_SpawnMenu.

Публичный контракт: UiKind/UiCommand, LuaRuntime::ui_toggle/ui_visible/
ui_snapshot/ui_mouse/ui_set_screen, load_ui -> UiLoad.

Попутно: file.Find в GMod-семантике (файлы и каталоги, маски *.txt),
DEFINE_BASECLASS -> local BaseClass, //-комментарии, регистронезависимый
include, SpawnableEntities хранит таблицу ENT.

Тесты на реальных ассетах: ~991 команда, вкладки/категории/модели,
клик по вкладке меняет снимок; без ассетов пропуск.
2026-10-24 15:03:32 +03:00

210 lines
6.8 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.

//! Настоящий `include()`: поиск в `lua/`, препроцессор, циклы и песочница.
use glua::LuaRuntime;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
static NEXT_DIR: AtomicUsize = AtomicUsize::new(0);
/// Уникальный временный каталог игры для одного теста.
struct TempGame {
root: PathBuf,
}
impl TempGame {
fn new(name: &str) -> Self {
let root = std::env::temp_dir().join(format!(
"glua_include_{name}_{}_{}",
std::process::id(),
NEXT_DIR.fetch_add(1, Ordering::Relaxed)
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join("lua")).expect("каталог игры");
Self { root }
}
fn write(&self, relative: &str, contents: &str) {
let path = self.root.join(relative);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).expect("каталог скрипта");
}
std::fs::write(path, contents).expect("файл скрипта");
}
fn path(&self) -> &Path {
&self.root
}
}
impl Drop for TempGame {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.root);
}
}
#[test]
fn include_preprocesses_and_does_not_repeat_files() {
let game = TempGame::new("basic");
game.write("lua/inc/counter.lua", "counter = ( counter or 0 ) + 1\n");
game.write(
"lua/inc/syntax.lua",
r#"
local out = {}
for i = 1, 3 do
if ( i == 2 ) then continue end
out[ #out + 1 ] = i
end
continued = table.concat( out, "" )
if ( !flag ) then not_flag = true end
"#,
);
game.write(
"lua/inc/cycle_a.lua",
"cycle_a = ( cycle_a or 0 ) + 1\ninclude( \"inc/cycle_b.lua\" )\n",
);
game.write(
"lua/inc/cycle_b.lua",
"cycle_b = ( cycle_b or 0 ) + 1\ninclude( \"inc/cycle_a.lua\" )\n",
);
let rt = LuaRuntime::new().expect("рантайм");
rt.set_file_root(game.path()).expect("корень");
rt.run(
"include_test.lua",
br#"
flag = false
include( "inc/counter.lua" )
include( "inc/counter.lua" )
include( "inc/syntax.lua" )
include( "inc/cycle_a.lua" )
"#,
)
.expect("include выполнился");
assert_eq!(
rt.probe("return tostring(counter)").expect("probe"),
Some("1".into())
);
// Препроцессор тот же: continue превратился в goto, `!` — в not.
assert_eq!(
rt.probe("return continued").expect("probe"),
Some("13".into())
);
assert_eq!(
rt.probe("return tostring(not_flag)").expect("probe"),
Some("true".into())
);
// Цикл a -> b -> a не зациклился и не выполнил a второй раз.
assert_eq!(
rt.probe("return tostring(cycle_a) .. tostring(cycle_b)")
.expect("probe"),
Some("11".into())
);
}
#[test]
fn include_refuses_escape_and_oversized_files() {
let game = TempGame::new("sandbox");
game.write("lua/ok.lua", "ok = true\n");
let secret_name = format!(
"glua_include_secret_{}_{}.lua",
std::process::id(),
NEXT_DIR.fetch_add(1, Ordering::Relaxed)
);
let outside = game.path().parent().expect("родитель").join(&secret_name);
std::fs::write(&outside, "leaked = true\n").expect("файл вне корня");
let rt = LuaRuntime::new().expect("рантайм");
rt.set_file_root(game.path()).expect("корень");
rt.run("ok.lua", b"include( \"ok.lua\" )")
.expect("обычный include");
let script = format!("include( \"../../{secret_name}\" )");
let escape = rt.run("escape.lua", script.as_bytes());
assert!(escape.is_err(), "выход за пределы каталога игры запрещён");
game.write(
"lua/big.lua",
&"-- большой файл\n".repeat((1 << 20) / 16 + 1),
);
let big = rt.run("big.lua", b"include( \"big.lua\" )");
assert!(big.is_err(), "файл больше лимита должен быть отклонён");
let _ = std::fs::remove_file(outside);
}
#[test]
fn load_gamemode_collects_errors_and_continues() {
let game = TempGame::new("gamemode");
game.write(
"gamemodes/test/gamemode/init.lua",
"error( \"init упал\" )\n",
);
game.write(
"gamemodes/test/gamemode/cl_init.lua",
"include( \"shared.lua\" )\ncl_loaded = true\n",
);
game.write(
"gamemodes/test/gamemode/shared.lua",
"shared_loaded = true\n",
);
let rt = LuaRuntime::new().expect("рантайм");
let report = rt.load_gamemode(game.path(), "test");
assert_eq!(report.loaded, vec!["cl_init.lua".to_string()]);
assert_eq!(report.errors.len(), 1, "{:?}", report.errors);
assert!(
report.errors[0].contains("init упал"),
"{:?}",
report.errors
);
assert_eq!(
rt.probe("return tostring(cl_loaded) .. tostring(shared_loaded)")
.expect("probe"),
Some("truetrue".into()),
"cl_init и include рядом с геймодом должны выполниться"
);
// Имя режима не должно становиться путём.
let bad = rt.load_gamemode(game.path(), "../escape");
assert_eq!(bad.loaded.len(), 0);
assert!(!bad.errors.is_empty());
}
#[test]
fn include_finds_windows_case_on_linux() {
let game = TempGame::new("case");
game.write("lua/gui/IconEditor.lua", "case_loaded = true\n");
let rt = LuaRuntime::new().expect("рантайм");
rt.set_file_root(game.path()).expect("корень");
rt.run("case.lua", br#"include( "gui/IconEditor.lua" )"#)
.expect("include без учёта регистра");
assert_eq!(
rt.probe("return tostring(case_loaded)").expect("probe"),
Some("true".into())
);
}
#[test]
fn include_limits_depth() {
let game = TempGame::new("depth");
for index in 1..=10 {
game.write(
&format!("lua/depth/f{index}.lua"),
&format!(
"depth = ( depth or 0 ) + 1\ninclude( \"depth/f{}.lua\" )\n",
index + 1
),
);
}
// Последний файл цепочки ничего не включает.
game.write("lua/depth/f11.lua", "depth_end = true\n");
let rt = LuaRuntime::new().expect("рантайм");
rt.set_file_root(game.path()).expect("корень");
let result = rt.run("depth.lua", b"include( \"depth/f1.lua\" )");
let error = result.expect_err("глубина должна быть ограничена");
assert!(
error.to_string().contains("глубина"),
"неожиданная ошибка: {error}"
);
}