Files
Marcos Paulo ca0d68f9ab
ci / ci (macos-latest) (push) Has been cancelled
ci / ci (ubuntu-latest) (push) Has been cancelled
ci / ci (windows-latest) (push) Has been cancelled
feat: export.pandoc, properties.rename_all, first wasm plugin (dice) (MiniMax M3.1 Flash)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 06:39:51 -03:00

160 lines
6.8 KiB
Rust

//! The first first-party plugin: the dice module, loaded from disk exactly as
//! a user plugin is (SPEC 7.2, 7.4).
//!
//! The build is not part of the test: run `plugins/dice/build.sh` first. The
//! tests are `#[ignore]`d, so a checkout without the module keeps the suite
//! green, and they print why they stop when the module is missing.
mod common;
use std::path::{Path, PathBuf};
use common::{seed, temp_vault};
use notescraft_engine::{AppSettings, Buffer, Registry, Session, register_core};
use notescraft_plugins::Permission;
use serde_json::{Value, json};
/// The folder of the plugin, relative to the crate.
fn plugin_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../plugins/dice")
}
/// The module and the manifest of the plugin, or `None` when it was not built.
fn built() -> Option<(PathBuf, PathBuf)> {
let module = plugin_dir().join("dice.wasm");
let manifest = plugin_dir().join("manifest.json");
(module.exists() && manifest.exists()).then_some((module, manifest))
}
/// The settings that enable the plugin and approve the `editor` permission.
fn settings() -> AppSettings {
let mut settings = AppSettings::default();
settings.plugins.enabled.insert("dice".to_owned());
settings.plugins.approved.insert("dice".to_owned(), vec![Permission::Editor]);
settings
}
/// Runs `body` in a session whose vault holds the dice plugin.
fn with_dice<T>(body: impl FnOnce(&mut Session) -> T) -> Option<T> {
let Some((module, manifest)) = built() else {
println!("skipped: {} is missing; run plugins/dice/build.sh", plugin_dir().join("dice.wasm").display());
return None;
};
let vault = temp_vault("dice");
seed(vault.path(), &[("note.md", "roll: ")]);
let folder = vault.path().join(".notescraft/plugins/dice");
std::fs::create_dir_all(&folder).expect("plugin folder");
std::fs::copy(&module, folder.join("plugin.wasm")).expect("copy the module");
std::fs::copy(&manifest, folder.join("manifest.json")).expect("copy the manifest");
let mut session = Session::open(vault.path()).expect("session");
session.apply_app_settings(&settings()).expect("settings");
session.load_plugins().expect("load plugins");
Some(body(&mut session))
}
#[test]
#[ignore = "needs plugins/dice/dice.wasm: run plugins/dice/build.sh"]
fn the_plugin_loads_and_rolls() {
let Some(result) = with_dice(|session| {
// The host refuses a module whose `ns_abi_version` is not 1 and every
// missing export, so an empty diagnostics list is the ABI check.
assert!(session.diagnostics().is_empty(), "{:?}", session.diagnostics());
let infos = session.plugin_infos();
assert_eq!(infos.len(), 1);
assert_eq!(infos.first().map(|info| info.id.as_str()), Some("dice"));
// The manifest of the plugin is the one that ships: it declares `roll`.
let mut registry = Registry::new();
register_core(&mut registry).expect("core commands");
assert_eq!(session.register_plugin_commands(&mut registry).expect("register"), 1);
assert!(registry.get("plugin.dice.roll").is_some());
session.run_plugin_command("plugin.dice.roll", r#"{"expr":"1d1"}"#).expect("the plugin rolls")
}) else {
return;
};
let value: Value = serde_json::from_str(&result).expect("the answer is JSON");
assert_eq!(value.get("total"), Some(&json!(1)));
assert_eq!(value.get("rolls"), Some(&json!([1])));
assert_eq!(value.get("text"), Some(&json!("1d1 = 1 [1]")));
}
#[test]
#[ignore = "needs plugins/dice/dice.wasm: run plugins/dice/build.sh"]
fn the_roll_is_inserted_into_the_focused_note() {
let Some(text) = with_dice(|session| {
session.open_buffer("note.md").expect("buffer");
session.focus_note("note.md").expect("focus");
let result = session.run_plugin_command("plugin.dice.roll", r#"{"expr":"2d6+3"}"#).expect("the plugin rolls");
let value: Value = serde_json::from_str(&result).expect("the answer is JSON");
let text = value.get("text").and_then(Value::as_str).expect("the answer has a text").to_owned();
let buffer = session.buffer("note.md").map(Buffer::text).expect("the buffer is open").to_owned();
assert_eq!(buffer, format!("roll: {text}"), "the host inserted the text");
assert_eq!(buffer.matches(&text).count(), 1, "the text is inserted once");
text
}) else {
return;
};
assert!(text.starts_with("2d6+3 = "), "{text}");
assert!(text.ends_with(" +3"), "{text}");
}
#[test]
#[ignore = "needs plugins/dice/dice.wasm: run plugins/dice/build.sh"]
fn a_hundred_dice_stay_inside_the_fuel_budget() {
let Some(result) = with_dice(|session| {
// The default budget is 50 million fuel; a hundred dice must not come
// close to it, so the call has to answer instead of hitting the limit.
session.run_plugin_command("plugin.dice.roll", r#"{"expr":"100d1000"}"#).expect("the plugin rolls")
}) else {
return;
};
let value: Value = serde_json::from_str(&result).expect("the answer is JSON");
let rolls = value.get("rolls").and_then(Value::as_array).expect("the answer has the rolls");
assert_eq!(rolls.len(), 100);
let mut sum = 0u64;
for die in rolls {
let sides = die.as_u64().expect("a die is a number");
assert!((1..=1000).contains(&sides), "{sides}");
sum = sum.saturating_add(sides);
}
let total = value.get("total").and_then(Value::as_i64).expect("the answer has the total");
assert_eq!(total, i64::try_from(sum).expect("the sum fits"));
}
#[test]
#[ignore = "needs plugins/dice/dice.wasm: run plugins/dice/build.sh"]
fn an_expression_out_of_bounds_is_a_guest_error() {
// Three refusals are the most a plugin may collect before the host
// disables it, so this session runs exactly three.
let Some(errors) = with_dice(|session| {
let mut errors = Vec::new();
for expr in ["0d6", "101d6", "1d1001"] {
let args = format!(r#"{{"expr":"{expr}"}}"#);
let error = session.run_plugin_command("plugin.dice.roll", &args).expect_err("the expression is refused");
errors.push(error.to_string());
}
errors
}) else {
return;
};
for error in &errors {
assert!(error.contains("error code"), "{error}");
assert!(!error.contains("trap"), "{error}");
}
}
#[test]
#[ignore = "needs plugins/dice/dice.wasm: run plugins/dice/build.sh"]
fn a_kept_expression_is_a_guest_error() {
let Some(error) = with_dice(|session| {
let error = session.run_plugin_command("plugin.dice.roll", r#"{"expr":"2d6kh3"}"#).expect_err("kh is not supported");
error.to_string()
}) else {
return;
};
assert!(error.contains("error code"), "{error}");
assert!(!error.contains("trap"), "{error}");
}