html terminal
split into more files
bendn 2023-06-17
parent d63233b · commit 5a1df00
-rw-r--r--Cargo.lock18
-rw-r--r--Cargo.toml1
-rw-r--r--src/bot/admin.rs35
-rw-r--r--src/bot/bans.rs36
-rw-r--r--src/bot/config.rs55
-rw-r--r--src/bot/js.rs49
-rw-r--r--src/bot/maps.rs25
-rw-r--r--src/bot/mod.rs241
-rw-r--r--src/bot/player.rs38
-rw-r--r--src/bot/status.rs91
10 files changed, 371 insertions, 218 deletions
diff --git a/Cargo.lock b/Cargo.lock
index b1bdf0c..66cdfb9 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -354,6 +354,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e"
[[package]]
+name = "convert_case"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca"
+dependencies = [
+ "unicode-segmentation",
+]
+
+[[package]]
name = "core-foundation-sys"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -473,7 +482,7 @@ version = "0.99.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fb810d30a7c1953f91334de7244731fc3f3c10d7fe163338a35b9f640960321"
dependencies = [
- "convert_case",
+ "convert_case 0.4.0",
"proc-macro2",
"quote",
"rustc_version",
@@ -1089,6 +1098,7 @@ dependencies = [
"anyhow",
"async-std",
"axum",
+ "convert_case 0.6.0",
"futures",
"futures-util",
"itertools",
@@ -1966,6 +1976,12 @@ dependencies = [
]
[[package]]
+name = "unicode-segmentation"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1dd624098567895118886609431a7c3b8f516e41d30e0643f03d94592a147e36"
+
+[[package]]
name = "untrusted"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
index 926f69a..87b18b4 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -39,6 +39,7 @@ anyhow = "1.0.71"
regex = { version = "1.8.4", features = ["std"], default-features = false }
minify-js = "0.4.3"
itertools = "0.10.5"
+convert_case = "0.6.0"
[profile.release]
lto = true
diff --git a/src/bot/admin.rs b/src/bot/admin.rs
new file mode 100644
index 0000000..b8b919b
--- /dev/null
+++ b/src/bot/admin.rs
@@ -0,0 +1,35 @@
+use super::{Context, Result};
+use crate::bot::player::{self, Players};
+use crate::send_ctx;
+
+#[poise::command(slash_command, category = "Configuration", rename = "add_admin")]
+/// make somebody a admin
+pub async fn add(
+ ctx: Context<'_>,
+ #[description = "The player to make admin"]
+ #[autocomplete = "player::autocomplete"]
+ player: String,
+) -> Result<()> {
+ let player = Players::find(&ctx.data().stdin, player)
+ .await
+ .unwrap()
+ .unwrap();
+ send_ctx!(ctx, "admin add {}", player.uuid)?;
+ Ok(())
+}
+
+#[poise::command(slash_command, category = "Configuration", rename = "remove_admin")]
+/// remove the admin status
+pub async fn remove(
+ ctx: Context<'_>,
+ #[description = "The player to remove admin status from"]
+ #[autocomplete = "player::autocomplete"]
+ player: String,
+) -> Result<()> {
+ let player = Players::find(&ctx.data().stdin, player)
+ .await
+ .unwrap()
+ .unwrap();
+ send_ctx!(ctx, "admin remove {}", player.uuid)?;
+ Ok(())
+}
diff --git a/src/bot/bans.rs b/src/bot/bans.rs
new file mode 100644
index 0000000..96d101d
--- /dev/null
+++ b/src/bot/bans.rs
@@ -0,0 +1,36 @@
+use super::{Context, Result};
+use crate::bot::player::{self, Players};
+use crate::{return_next, send_ctx};
+
+#[poise::command(slash_command, category = "Control", rename = "ban")]
+/// ban a player by uuid and ip
+pub async fn add(
+ ctx: Context<'_>,
+ #[description = "player to ban"]
+ #[autocomplete = "player::autocomplete"]
+ player: String,
+) -> Result<()> {
+ let _ = ctx.defer().await;
+ let player = Players::find(&ctx.data().stdin, player)
+ .await
+ .unwrap()
+ .unwrap();
+ send_ctx!(ctx, "ban ip {}", player.ip)?;
+ send_ctx!(ctx, "ban id {}", player.uuid)?;
+ return_next!(ctx)
+}
+
+#[poise::command(slash_command, category = "Control", rename = "unban")]
+/// unban a player by uuid or ip
+pub async fn remove(
+ ctx: Context<'_>,
+ #[description = "Player id/ip"]
+ #[rename = "ip_or_id"]
+ player: String,
+) -> Result<()> {
+ let _ = ctx.defer().await;
+ send_ctx!(ctx, "unban {}", player)?;
+ return_next!(ctx)
+}
+
+// TODO: listbans
diff --git a/src/bot/config.rs b/src/bot/config.rs
new file mode 100644
index 0000000..a68a905
--- /dev/null
+++ b/src/bot/config.rs
@@ -0,0 +1,55 @@
+use super::{Context, Result};
+use crate::{return_next, send_ctx};
+use convert_case::{Case, Casing};
+use futures_util::StreamExt;
+
+const ITEMS: &'static [&'static str] = &[
+ "autoUpdate",
+ "showConnectMessages",
+ "enableVotekick",
+ "startCommands",
+ "logging",
+ "strict",
+ "antiSpam",
+ "interactRateWindow",
+ "interactRateKick",
+ "messageRateLimit",
+ "messageSpamKick",
+ "packetSpamLimit",
+ "chatSpamLimit",
+ "socketInput",
+ "socketInputPort",
+ "socketInputAddress",
+ "allowCustomClients",
+ "whitelist",
+ "motd",
+ "autosave",
+ "autosaveAmount",
+ "debug",
+ "snapshotInterval",
+ "autoPause",
+];
+
+async fn complete<'a>(
+ _ctx: Context<'_>,
+ partial: &'a str,
+) -> impl futures::Stream<Item = String> + 'a {
+ futures::stream::iter(ITEMS)
+ .filter(move |name| futures::future::ready(name.starts_with(partial)))
+ .map(|name| name.from_case(Case::Camel).to_case(Case::Lower))
+}
+
+#[poise::command(slash_command, category = "Configuration", rename = "config")]
+/// change a setting
+pub async fn set(
+ ctx: Context<'_>,
+ #[autocomplete = "complete"]
+ #[description = "setting to change"]
+ setting: String,
+ #[description = "the value"] config: String,
+) -> Result<()> {
+ let setting = setting.from_case(Case::Lower).to_case(Case::Camel);
+ send_ctx!(ctx, "config {setting} {config}")?;
+ return_next!(ctx)
+}
+// TODO: config::list
diff --git a/src/bot/js.rs b/src/bot/js.rs
new file mode 100644
index 0000000..38f839d
--- /dev/null
+++ b/src/bot/js.rs
@@ -0,0 +1,49 @@
+use super::{Context, Result};
+use crate::{return_next, send_ctx};
+use minify_js::TopLevelMode;
+use regex::Regex;
+use std::sync::LazyLock;
+
+fn parse_js(from: &str) -> Result<String> {
+ static RE: LazyLock<Regex> =
+ LazyLock::new(|| Regex::new(r#"```(js|javascript)?([^`]+)```"#).unwrap());
+ let mat = RE
+ .captures(&from)
+ .ok_or(anyhow::anyhow!(r#"no code found (use \`\`\`js...\`\`\`."#))?;
+ let script = mat.get(2).unwrap().as_str();
+ let mut out = vec![];
+ Ok(
+ if minify_js::minify(TopLevelMode::Global, script.into(), &mut out).is_ok() {
+ String::from_utf8_lossy(&out).to_string()
+ } else {
+ script.replace('\n', ";")
+ },
+ )
+}
+
+#[poise::command(
+ prefix_command,
+ required_permissions = "USE_SLASH_COMMANDS",
+ category = "Control",
+ track_edits,
+ rename = "js"
+)]
+/// run arbitrary javascript
+pub async fn run(
+ ctx: Context<'_>,
+ #[description = "Script"]
+ #[rest]
+ script: String,
+) -> Result<()> {
+ let _ = ctx.channel_id().start_typing(&ctx.serenity_context().http);
+ let script = parse_js(&script)?;
+ send_ctx!(ctx, "js {script}")?;
+ return_next!(ctx)
+}
+
+#[test]
+fn test_parse_js() {
+ assert!(
+ parse_js("```js\nLog.info(4)\nLog.info(4+2)\n```").unwrap() == "Log.info(4);Log.info(4+ 2)" // hmm
+ )
+}
diff --git a/src/bot/maps.rs b/src/bot/maps.rs
index ba330d7..807f8df 100644
--- a/src/bot/maps.rs
+++ b/src/bot/maps.rs
@@ -1,4 +1,4 @@
-use super::{get_nextblock, strip_colors, Context};
+use super::{get_nextblock, strip_colors, Context, Result, SUCCESS};
use crate::send;
use futures_util::StreamExt;
use tokio::sync::broadcast;
@@ -39,3 +39,26 @@ pub async fn autocomplete<'a>(
.filter(move |name| futures::future::ready(name.starts_with(partial)))
.map(|name| name.to_string())
}
+
+#[poise::command(
+ slash_command,
+ prefix_command,
+ required_permissions = "USE_SLASH_COMMANDS",
+ category = "Info",
+ rename = "maps"
+)]
+/// lists the maps.
+pub async fn list(ctx: Context<'_>) -> Result<()> {
+ let _ = ctx.defer_or_broadcast().await;
+ let maps = Maps::get_all(&ctx.data().stdin).await;
+ poise::send_reply(ctx, |m| {
+ m.embed(|e| {
+ for (k, v) in maps.iter().enumerate() {
+ e.field((k + 1).to_string(), v, true);
+ }
+ e.description("map list.").color(SUCCESS)
+ })
+ })
+ .await?;
+ Ok(())
+}
diff --git a/src/bot/mod.rs b/src/bot/mod.rs
index 7386f2b..a6d56e7 100644
--- a/src/bot/mod.rs
+++ b/src/bot/mod.rs
@@ -1,18 +1,19 @@
+mod admin;
+mod bans;
+mod config;
+mod js;
mod maps;
mod player;
+mod status;
use crate::webhook::Webhook;
-use anyhow::{anyhow, Result};
-use itertools::Itertools;
+use anyhow::Result;
use maps::Maps;
-use minify_js::TopLevelMode;
-use player::Players;
-use regex::Regex;
-use serenity::http::{CacheHttp, Http};
+
+use serenity::http::Http;
use serenity::prelude::*;
use std::fs::read_to_string;
-use std::str::FromStr;
-use std::sync::{Arc, LazyLock, Mutex, OnceLock};
+use std::sync::{Arc, Mutex, OnceLock};
use tokio::sync::broadcast;
pub struct Data {
@@ -28,6 +29,7 @@ macro_rules! send {
};
}
+#[macro_export]
macro_rules! send_ctx {
($e:expr,$fmt:literal $(, $args:expr)* $(,)?) => {
$e.data().stdin.send(format!($fmt $(, $args)*))
@@ -51,14 +53,15 @@ impl Bot {
commands: vec![
raw(),
say(),
- ban(),
- unban(),
- add_admin(),
- remove_admin(),
- js(),
- maps(),
- players(),
- status(),
+ bans::add(),
+ bans::remove(),
+ admin::add(),
+ admin::remove(),
+ js::run(),
+ maps::list(),
+ player::list(),
+ status::command(),
+ config::set(),
start(),
end(),
help(),
@@ -99,7 +102,8 @@ type Context<'a> = poise::Context<'a, Data, anyhow::Error>;
#[poise::command(
prefix_command,
required_permissions = "USE_SLASH_COMMANDS",
- category = "Control"
+ category = "Control",
+ track_edits
)]
/// send a raw command to the server
async fn raw(
@@ -109,15 +113,15 @@ async fn raw(
cmd: String,
) -> Result<()> {
send_ctx!(ctx, "{cmd}")?;
- println!("sent");
Ok(())
}
+#[macro_export]
macro_rules! return_next {
($ctx:expr) => {{
- let line = get_nextblock().await;
+ let line = crate::bot::get_nextblock().await;
$ctx.send(|m| m.content(line)).await?;
- Ok(())
+ return Ok(());
}};
}
@@ -141,69 +145,6 @@ async fn say(ctx: Context<'_>, #[description = "Message"] message: String) -> Re
return_next!(ctx)
}
-#[poise::command(slash_command, category = "Control")]
-/// ban a player by uuid and ip
-async fn ban(
- ctx: Context<'_>,
- #[description = "player to ban"]
- #[autocomplete = "player::autocomplete"]
- player: String,
-) -> Result<()> {
- let _ = ctx.defer().await;
- let player = Players::find(&ctx.data().stdin, player)
- .await
- .unwrap()
- .unwrap();
- send_ctx!(ctx, "ban ip {}", player.ip)?;
- send_ctx!(ctx, "ban id {}", player.uuid)?;
- return_next!(ctx)
-}
-
-#[poise::command(slash_command, category = "Control")]
-/// unban a player by uuid or ip
-async fn unban(
- ctx: Context<'_>,
- #[description = "Player id/ip"]
- #[rename = "ip_or_id"]
- player: String,
-) -> Result<()> {
- let _ = ctx.defer().await;
- send_ctx!(ctx, "unban {}", player)?;
- return_next!(ctx)
-}
-
-#[poise::command(
- prefix_command,
- required_permissions = "USE_SLASH_COMMANDS",
- category = "Control",
- track_edits
-)]
-/// run arbitrary javascript
-async fn js(
- ctx: Context<'_>,
- #[description = "Script"]
- #[rest]
- script: String,
-) -> Result<()> {
- static RE: LazyLock<Regex> =
- LazyLock::new(|| Regex::new(r#"```(js|javascript)?([^`]+)```"#).unwrap());
- let _ = ctx.channel_id().start_typing(&ctx.serenity_context().http);
- let mat = RE
- .captures(&script)
- .ok_or(anyhow::anyhow!(r#"no code found (use \`\`\`js...\`\`\`."#))?;
- let script = mat.get(2).unwrap().as_str();
- let mut out = vec![];
- let script = if minify_js::minify(TopLevelMode::Global, script.into(), &mut out).is_err() {
- std::borrow::Cow::from(script.replace('\n', ";")) // xd
- } else {
- String::from_utf8_lossy(&out)
- };
- send_ctx!(ctx, "js {script}")?;
- let line = get_nextblock().await;
- ctx.send(|m| m.content(line)).await?;
- Ok(())
-}
-
fn strip_colors(from: &str) -> String {
let mut result = String::new();
result.reserve(from.len());
@@ -223,98 +164,6 @@ fn strip_colors(from: &str) -> String {
#[poise::command(
slash_command,
required_permissions = "USE_SLASH_COMMANDS",
- category = "Info"
-)]
-/// lists the maps.
-pub async fn maps(ctx: Context<'_>) -> Result<()> {
- let _ = ctx.defer().await;
- let maps = Maps::get_all(&ctx.data().stdin).await;
- poise::send_reply(ctx, |m| {
- m.embed(|e| {
- for (k, v) in maps.iter().enumerate() {
- e.field((k + 1).to_string(), v, true);
- }
- e.description("map list.").color(SUCCESS)
- })
- })
- .await?;
- Ok(())
-}
-
-#[poise::command(prefix_command, slash_command, category = "Info")]
-/// server status.
-pub async fn status(ctx: Context<'_>) -> Result<()> {
- let _ = ctx.defer_or_broadcast().await;
- send_ctx!(ctx, "status")?;
- let block = tokio::select! {
- block = get_nextblock() => block,
- _ = async_std::task::sleep(std::time::Duration::from_secs(5)) =>
- { poise::send_reply(ctx, |m| m.embed(|e| e.title("server down").color(FAIL))).await?; return Ok(()) },
- };
- let (tps, mem, pcount) = block
- .split('/')
- .map(|s| u32::from_str(s.trim().split_once(' ').unwrap().0).unwrap())
- .collect_tuple()
- .ok_or(anyhow!("couldnt split block"))?;
- poise::send_reply(ctx, |m| {
- m.embed(|e| {
- if pcount > 0 {
- e.footer(|f| f.text("see /players for player list"));
- }
- e.title("server online")
- .field("tps", tps, true)
- .field("memory use", mem, true)
- .field("players", pcount, true)
- .color(SUCCESS)
- })
- })
- .await?;
- Ok(())
-}
-
-#[poise::command(slash_command, prefix_command, category = "Info")]
-/// lists the currently online players.
-pub async fn players(ctx: Context<'_>) -> Result<()> {
- let _ = ctx.defer().await;
- let players = Players::get_all(&ctx.data().stdin).await.unwrap().clone();
- let perms = ctx
- .partial_guild()
- .await
- .unwrap()
- .member_permissions(ctx.http(), ctx.author().id)
- .await?;
- // let perms = ctx
- // .author_member()
- // .await
- // .ok_or(anyhow!("couldnt get perms"))?
- // .permissions(ctx.cache().unwrap())?;
- poise::send_reply(ctx, |m| {
- m.embed(|e| {
- if players.is_empty() {
- return e.title("no players online.").color(FAIL);
- }
- e.fields(players.into_iter().map(|p| {
- let admins = if p.admin { " [A]" } else { "" };
- (
- p.name,
- if perms.use_slash_commands() {
- format!("{id}, {ip}", id = p.uuid, ip = p.ip) + admins
- } else {
- admins.to_string()
- },
- true,
- )
- }));
- e.description("currently online players.").color(SUCCESS)
- })
- })
- .await?;
- Ok(())
-}
-
-#[poise::command(
- slash_command,
- required_permissions = "USE_SLASH_COMMANDS",
category = "Control"
)]
/// start the game.
@@ -350,49 +199,11 @@ pub async fn end(
return_next!(ctx)
}
-#[poise::command(slash_command, category = "Configuration")]
-/// make somebody a admin
-pub async fn add_admin(
- ctx: Context<'_>,
- #[description = "The player to make admin"]
- #[autocomplete = "player::autocomplete"]
- player: String,
-) -> Result<()> {
- let player = Players::find(&ctx.data().stdin, player)
- .await
- .unwrap()
- .unwrap();
- send_ctx!(ctx, "admin add {}", player.uuid)?;
- Ok(())
-}
-
-#[poise::command(slash_command, category = "Configuration")]
-/// remove the admin status
-pub async fn remove_admin(
- ctx: Context<'_>,
- #[description = "The player to remove admin status from"]
- #[autocomplete = "player::autocomplete"]
- player: String,
-) -> Result<()> {
- let player = Players::find(&ctx.data().stdin, player)
- .await
- .unwrap()
- .unwrap();
- send_ctx!(ctx, "admin remove {}", player.uuid)?;
- Ok(())
-}
-
-#[poise::command(
- prefix_command,
- slash_command,
- required_permissions = "USE_SLASH_COMMANDS",
- track_edits,
- category = "Info"
-)]
+#[poise::command(prefix_command, slash_command, track_edits, category = "Info")]
/// show help and stuff
pub async fn help(
ctx: Context<'_>,
- #[description = "Specific command to show help about"]
+ #[description = "command to show help about"]
#[autocomplete = "poise::builtins::autocomplete_command"]
command: Option<String>,
) -> Result<()> {
diff --git a/src/bot/player.rs b/src/bot/player.rs
index 29b534d..f0d6a0f 100644
--- a/src/bot/player.rs
+++ b/src/bot/player.rs
@@ -1,8 +1,9 @@
-use super::{get_nextblock, strip_colors, Context};
+use super::{get_nextblock, strip_colors, Context, FAIL, SUCCESS};
use crate::send;
use anyhow::Result;
use futures_util::StreamExt;
use itertools::Itertools;
+use serenity::http::CacheHttp;
use std::net::Ipv4Addr;
use std::str::FromStr;
use std::time::Instant;
@@ -82,3 +83,38 @@ pub async fn autocomplete<'a>(
.filter(move |p| futures::future::ready(p.name.starts_with(partial)))
.map(|p| p.name)
}
+
+#[poise::command(slash_command, prefix_command, category = "Info", rename = "players")]
+/// lists the currently online players.
+pub async fn list(ctx: Context<'_>) -> Result<()> {
+ let _ = ctx.defer().await;
+ let players = Players::get_all(&ctx.data().stdin).await.unwrap().clone();
+ let perms = ctx
+ .partial_guild()
+ .await
+ .unwrap()
+ .member_permissions(ctx.http(), ctx.author().id)
+ .await?;
+ poise::send_reply(ctx, |m| {
+ m.embed(|e| {
+ if players.is_empty() {
+ return e.title("no players online.").color(FAIL);
+ }
+ e.fields(players.into_iter().map(|p| {
+ let admins = if p.admin { " [A]" } else { "" };
+ (
+ p.name,
+ if perms.use_slash_commands() {
+ format!("{id}, {ip}", id = p.uuid, ip = p.ip) + admins
+ } else {
+ admins.to_string()
+ },
+ true,
+ )
+ }));
+ e.description("currently online players.").color(SUCCESS)
+ })
+ })
+ .await?;
+ Ok(())
+}
diff --git a/src/bot/status.rs b/src/bot/status.rs
new file mode 100644
index 0000000..6926ab0
--- /dev/null
+++ b/src/bot/status.rs
@@ -0,0 +1,91 @@
+use super::{get_nextblock, Context, FAIL, SUCCESS};
+use crate::send_ctx;
+use anyhow::Result;
+use itertools::Itertools;
+use std::str::FromStr;
+
+fn parse(line: &str) -> Option<(u32, u32, u32)> {
+ line.split('/')
+ .map(|s| u32::from_str(s.trim().split_once(' ').unwrap().0).unwrap())
+ .collect_tuple()
+}
+
+#[allow(dead_code)]
+pub enum Size {
+ Gb(f64),
+ Mb(f64),
+ Kb(f64),
+ B(f64),
+}
+const UNIT: f64 = 1024.0;
+
+impl Size {
+ pub fn bytes(self) -> f64 {
+ match self {
+ Self::B(x) => x,
+ Self::Kb(x) => x * UNIT,
+ Self::Mb(x) => x * UNIT * UNIT,
+ Self::Gb(x) => x * UNIT * UNIT * UNIT,
+ }
+ }
+}
+// https://git.sr.ht/~f9/human_bytes
+pub fn humanize_bytes<T: Into<Size>>(bytes: T) -> String {
+ const SUFFIX: [&str; 4] = ["B", "KB", "MB", "GB"];
+ let size = dbg!(bytes.into().bytes());
+
+ if size <= 0.0 {
+ return "0 B".to_string();
+ }
+
+ let base = size.log10() / UNIT.log10();
+
+ let result = format!("{:.1}", UNIT.powf(base - base.floor()),)
+ .trim_end_matches(".0")
+ .to_owned();
+
+ // Add suffix
+ [&result, SUFFIX[base.floor() as usize]].join(" ")
+}
+
+#[poise::command(prefix_command, slash_command, category = "Info", rename = "status")]
+/// server status.
+pub async fn command(ctx: Context<'_>) -> Result<()> {
+ let _ = ctx.defer_or_broadcast().await;
+ send_ctx!(ctx, "status")?;
+ let block = tokio::select! {
+ block = get_nextblock() => block,
+ _ = async_std::task::sleep(std::time::Duration::from_secs(5)) =>
+ { poise::send_reply(ctx, |m| m.embed(|e| e.title("server down").color(FAIL))).await?; return Ok(()) },
+ };
+ let (tps, mem, pcount) =
+ parse(&block).ok_or(anyhow::anyhow!("couldnt split block {block}."))?;
+ poise::send_reply(ctx, |m| {
+ m.embed(|e| {
+ if pcount > 0 {
+ e.footer(|f| f.text("see /players for player list"));
+ }
+ e.title("server online")
+ .field("tps", tps, true)
+ .field("memory use", humanize_bytes(Size::Mb(mem as f64)), true)
+ .field("players", pcount, true)
+ .color(SUCCESS)
+ })
+ })
+ .await?;
+ Ok(())
+}
+
+#[test]
+fn test_parse() {
+ assert!(parse("57 TPS / 274 MB / 7 PLAYERS") == Some((57, 274, 7)));
+}
+
+#[test]
+fn test_bytes() {
+ assert!(humanize_bytes(Size::B(0.0)) == "0 B");
+ assert!(humanize_bytes(Size::B(550.0)) == "550 B");
+ assert!(humanize_bytes(Size::Kb(550.0)) == "550 KB");
+ assert!(humanize_bytes(Size::Mb(650.0)) == "650 MB");
+ assert!(humanize_bytes(Size::Gb(15.3)) == "15.3 GB");
+}