Перейти к содержимому

Тест плагина без клиента

Меняется. Следите: #266, #323

На этой странице показано, как протестировать плагин без клиента Minecraft. Тест запускает сервер в своем процессе, подключает фиктивного игрока, прогоняет тики и проверяет пакеты, которые отправил сервер.

Если вы пишете плагины для Paper, вам может быть знаком MockBukkit, который дает фиктивный сервер и фиктивных игроков. В mcrs есть похожее окружение, но оно живет внутри интеграционных тестов крейта сервера. Отдельным крейтом оно не опубликовано, поэтому пользоваться им можно только из клона репозитория, как описано в туториале Первый плагин. Примеры взяты из файлов репозитория, вне репозиторных тестов мы их не запускали.

Тесты - модули одного тестового бинарника

Заголовок раздела «Тесты - модули одного тестового бинарника»

Все интеграционные тесты крейта сервера - модули одного тестового бинарника, tests/it/main.rs, и новый файл становится там модулем. Чтобы запустить один модуль, передайте фильтр. Вот команда, которая запускает тесты чата:1

Окно терминала
cargo test -p mcrs_minecraft_server --test it chat::

Окружение лежит в tests/it/host_app/mod.rs. Его функция make_host_app() собирает приложение хоста с наборами сообщений и реестрами, которые нужны тику измерения.

make_host_app
pub fn make_host_app() -> App {
let mut app = App::new();
// The worldgen startup uses AssetServer::load, which spawns
// onto IoTaskPool; TaskPoolPlugin must run first or sub-app Startup panics.
app.add_plugins(TaskPoolPlugin {
task_pool_options: bevy_app::TaskPoolOptions::with_num_threads(2),
});
add_assets(&mut app);
app.add_plugins(TimePlugin);
app.insert_resource(Time::<Fixed>::from_hz(20.0));
app.add_plugins(StatesPlugin);
app.init_state::<AppState>();
app.add_message::<OutboundPlayerPacket>();
app.add_message::<InboundPlayerPacket>();
app.add_message::<OutboundPlayerAttached>();
app.add_message::<InboundPlayerDespawn>();
app.add_plugins(DimCellsPlugin);
app.add_plugins(DimInputsPlugin);
app.add_message::<JoinRequested>();
app.add_message::<JoinPlaced>();
app.init_resource::<DimSpawnQueue>();
app.init_resource::<DimDespawnQueue>();
crate::support::insert_registries(&mut app);
app
}

crates/mcrs_minecraft_server/tests/it/host_app/mod.rs:81-108

Тест подключает фиктивного игрока и прогоняет тики

Заголовок раздела «Тест подключает фиктивного игрока и прогоняет тики»

Вот первая половина теста чата. Он добавляет плагин в измерения, подключает игрока, ставит в очередь строку чата и прогоняет два тика.

Тест: подключиться, отправить строку чата, собрать пакеты
for (case, mode, message, broadcast, reply) in cases {
let mut app = host_app::make_host_app();
app.add_dimension_plugin(ChatPlugin);
let mut server = PlayServer::start(app, &[], 0);
let (sender, _) = server.join(Uuid::from_u128(1), "Sender");
server.queue(sender, &client_information(mode));
server.queue(sender, &line(message));
let sent = server.ticks_all(2);
let heard: Vec<Text> = sent
.iter()
.filter(|packet| matches!(packet.target, PacketTarget::AllPlayers))
.filter_map(|packet| match &packet.data {
PacketPayload::SystemChat(chat) => {
Some(chat.content.to_text().unwrap().map(&ItemHoverMap).unwrap())
}
_ => None,
})
.collect();

crates/mcrs_minecraft_server/tests/it/chat.rs:79-97

Тест работает через PlayServer, обертку над приложением. Он использует такие вызовы:

Вызов Что делает
PlayServer::start(app, ops, default_level) Завершает сборку приложения и доводит его до состояния игры с одним верхним миром
join(uuid, name) Размещает фиктивного игрока и выполняет три обновления
queue(anchor, &packet) Отправляет пакет от клиента к серверу так, как будто он пришел из сокета
ticks_all(n) Выполняет n обновлений и возвращает все пакеты, которые отправил сервер

Тики считаются точно, и настенных часов ничто не ждет. Один тик хоста - это один update() плюс передача данных между хостом и измерениями, и ticks_all(1) выполняет и то и другое.

Вторая половина теста сравнивает услышанное с ожидаемым текстом:

Тест: проверить пакеты
if broadcast {
let expected = Text::translate(
"chat.type.text",
vec![
"Sender".to_owned().into_text(),
message.to_owned().into_text(),
],
);
assert_eq!(heard, [expected], "{case}");
} else {
assert!(heard.is_empty(), "{case}: {heard:?}");
}

crates/mcrs_minecraft_server/tests/it/chat.rs:98-109

Тест может заглянуть и внутрь мира. server.world() дает мир первого измерения, а server.world_in(dim) - мир того измерения, которое вы укажете. Тест наблюдателей чата добавляет туда наблюдателя и потом читает из мира ресурс.

Тест: наблюдатель в мире и чтение ресурса
#[test]
fn an_observer_that_replaces_a_chat_line_changes_its_broadcast() {
#[derive(Resource)]
struct Speaker(Entity);
fn censor(mut event: On<PlayerChatEvent>, mut commands: Commands) {
commands.insert_resource(Speaker(event.player));
event.text = "***".to_owned().into_text();
}
let mut app = host_app::make_host_app();
app.add_dimension_plugin(ChatPlugin);
let mut server = PlayServer::start(app, &[], 0);
let (sender, _) = server.join(Uuid::from_u128(1), "Sender");
server.world().add_observer(censor);
server.queue(sender, &line("hello"));
let heard: Vec<Text> = server
.ticks_all(2)
.iter()
.filter(|packet| matches!(packet.target, PacketTarget::AllPlayers))
.filter_map(|packet| match &packet.data {
PacketPayload::SystemChat(chat) => {
Some(chat.content.to_text().unwrap().map(&ItemHoverMap).unwrap())
}
_ => None,
})
.collect();
let expected = Text::translate(
"chat.type.text",
vec![
"Sender".to_owned().into_text(),
"***".to_owned().into_text(),
],
);
assert_eq!(heard, [expected]);
let player = server.player(sender);
assert_eq!(server.world().resource::<Speaker>().0, player);
}

crates/mcrs_minecraft_server/tests/it/chat.rs:144-181

Один тест ловит неупорядоченную пару систем

Заголовок раздела «Один тест ловит неупорядоченную пару систем»

Если две системы пишут одни и те же данные и порядок между ними не задан, они могут выполниться в любом порядке, и одни и те же входные данные дадут разные тики. В Bevy такую пару называют неоднозначностью. Ее находит планировщик, когда собирает расписание, а компилятор это правило не проверяет.

Один тест из репозитория собирает главное приложение и мир измерения с полным плагином сервера и превращает любую неоднозначность в расписаниях симуляции в ошибку.2 Когда неоднозначность появляется, тест падает и называет обе системы.

Тест: нет неупорядоченной конфликтующей пары
#[test]
fn no_simulation_schedule_has_an_unordered_conflicting_pair() {
let mut app = App::new();
let mut plugins = MinecraftServerPlugins::embedded();
plugins.core.asset_path = Some(ASSETS.to_owned());
plugins.network.announce_on_lan = false;
plugins.worlds.dimensions = Some(Dimensions::from([(
ResourceKeyBuf::from_location("mcrs:ambiguity".parse().expect("a valid identifier")),
DimensionEntry {
dimension_type: crate::support::dimension_type("minecraft:overworld"),
generator: ChunkGenerator::Debug,
},
)]));
app.add_plugins(plugins);
app.finish();
app.cleanup();
let mut reports = ambiguous_schedules("server", app.world_mut());
crate::support::update_until_playing(&mut app);
drain_dim_spawn_queue(&mut app);
let dimension = app
.world_mut()
.query_filtered::<bevy_ecs::entity::Entity, bevy_ecs::query::With<DimSubAppHandle>>()
.single(app.world())
.expect("one dimension is spawned");
let dimension_world = crate::host_app::world(&mut app, dimension);
reports.extend(ambiguous_schedules("dimension", dimension_world));
assert!(reports.is_empty(), "{}", reports.join("\n"));
}

crates/mcrs_minecraft_server/tests/it/schedule_ambiguity.rs:84-113

Тест собирает MinecraftServerPlugins::embedded(). Если вашего плагина в этом приложении нет, добавьте его в тест, иначе тест не увидит ваши системы. В репозитории неоднозначность устраняют, упорядочивая системы или разделяя данные, и общим игнорированием не пользуются.

Дальше: Перехват чата, где разобран тест с настоящим событием. Команды сборки и тестов репозитория есть в разделе Как внести вклад.

  1. Работает#636 Поднимает отменяемое событие для строки чата перед ее рассылкой. ↩

  2. Работает#323 Расписания симуляции падают, если порядок систем неоднозначен. ↩

  3. В работе#266 Планирует запуск одного измерения без сети, с фиксированным сидом, без игрока и с тикающей областью. ↩

Источники и проверка

Проверено: 26.4-snapshot-7, 51d45c26a, 2026-11-09

Переведено с версии 51d45c26a

Следить: #266, #323