You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
488 lines
18 KiB
488 lines
18 KiB
//! Tests for the tui module: `tui.styled` behavior and the prompt argument
|
|
//! validation that fires *before* the terminal is touched (so it's
|
|
//! deterministic in CI, tty or not). The interactive halves of the prompts
|
|
//! are exercised manually — see docs/tui.md.
|
|
|
|
use super::lua;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// module surface
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[test]
|
|
fn test_tui_global_has_all_functions() {
|
|
let lua = lua();
|
|
for f in [
|
|
"confirm",
|
|
"promptText",
|
|
"promptLongText",
|
|
"promptSelect",
|
|
"promptSecret",
|
|
"promptNumber",
|
|
"promptInt",
|
|
"promptDate",
|
|
"styled",
|
|
"block",
|
|
"success",
|
|
"error",
|
|
"warning",
|
|
"caution",
|
|
"info",
|
|
"note",
|
|
"comment",
|
|
"outlineBlock",
|
|
"outlineSuccess",
|
|
"outlineError",
|
|
"outlineWarning",
|
|
"outlineNote",
|
|
"outlineInfo",
|
|
"outlineCaution",
|
|
"raw",
|
|
"screenInfo",
|
|
"addPreset",
|
|
"moveTo",
|
|
"move",
|
|
"saveCursor",
|
|
"restoreCursor",
|
|
"cursorPos",
|
|
"showCursor",
|
|
"cursorStyle",
|
|
"clearLine",
|
|
"clearScreen",
|
|
"altScreen",
|
|
"scrollRegion",
|
|
"scroll",
|
|
"setTitle",
|
|
"setClipboard",
|
|
"reset",
|
|
] {
|
|
let is_fn: bool = lua
|
|
.load(format!("return type(tui.{f}) == 'function'"))
|
|
.eval()
|
|
.unwrap();
|
|
assert!(is_fn, "tui.{f} is missing or not a function");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_require_returns_the_global() {
|
|
let lua = lua();
|
|
// require needs roots installed; builtins resolve without touching the fs.
|
|
crate::stdlib::require::install(&lua, vec![]).unwrap();
|
|
let same: bool = lua.load(r#"return require("tui") == tui"#).eval().unwrap();
|
|
assert!(same);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// tui.styled
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// NOTE: whether escape codes are emitted depends on the test process's
|
|
// stdout (colored's tty/NO_COLOR detection), so these assertions only check
|
|
// properties that hold in both color modes. Exact byte output is pinned by
|
|
// the unit tests in stdlib/tui/markup.rs, which bypass the global detection.
|
|
|
|
#[test]
|
|
fn test_styled_returns_payload_text() {
|
|
let lua = lua();
|
|
let out: String = lua
|
|
.load(r#"return tui.styled("a <info>b</info> <fg=red;options=bold>c</> d")"#)
|
|
.eval()
|
|
.unwrap();
|
|
// Tags are consumed, payload text survives in order.
|
|
assert!(!out.contains("<info>"), "tag not consumed: {out:?}");
|
|
assert!(!out.contains("fg=red"), "tag not consumed: {out:?}");
|
|
let stripped: String = out.chars().filter(|c| " abcd".contains(*c)).collect();
|
|
assert_eq!(stripped, "a b c d");
|
|
}
|
|
|
|
#[test]
|
|
fn test_styled_resolves_escapes_and_keeps_invalid_tags() {
|
|
let lua = lua();
|
|
let out: String = lua
|
|
.load(r#"return tui.styled("\\<info>x <notatag> y")"#)
|
|
.eval()
|
|
.unwrap();
|
|
assert!(out.contains("<info>x"), "escape not resolved: {out:?}");
|
|
assert!(out.contains("<notatag>"), "invalid tag should stay literal: {out:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn test_styled_shorthand_and_html_tags_are_consumed() {
|
|
let lua = lua();
|
|
let out: String = lua
|
|
.load(r#"return tui.styled("<red;on-white;bold>a</> <b>b</b> <i>c</i> <u>d</u> <s>e</s>")"#)
|
|
.eval()
|
|
.unwrap();
|
|
assert!(!out.contains('<'), "tags not consumed: {out:?}");
|
|
let stripped: String = out.chars().filter(|c| " abcde".contains(*c)).collect();
|
|
assert_eq!(stripped, "a b c d e");
|
|
}
|
|
|
|
#[test]
|
|
fn test_add_preset() {
|
|
let lua = lua();
|
|
// A registered preset works as a styled tag and as a block style.
|
|
let out: String = lua
|
|
.load(
|
|
r#"
|
|
tui.addPreset("keyword", "fg=white;bg=magenta")
|
|
tui.addPreset("kw2", "keyword") -- specs may reference other presets
|
|
return tui.styled("<keyword>foo</keyword> <kw2>bar</>")
|
|
"#,
|
|
)
|
|
.eval()
|
|
.unwrap();
|
|
assert!(!out.contains("<keyword>"), "preset tag not consumed: {out:?}");
|
|
assert!(out.contains("foo") && out.contains("bar"));
|
|
lua.load(r#"tui.block("x", {style = "keyword"})"#).exec().unwrap();
|
|
|
|
// Presets are per Lua state: a fresh state doesn't see "keyword".
|
|
let other = super::lua();
|
|
let out: String = other.load(r#"return tui.styled("<keyword>foo</keyword>")"#).eval().unwrap();
|
|
assert!(out.contains("<keyword>"), "preset leaked across states: {out:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn test_add_preset_validation() {
|
|
let lua = lua();
|
|
for (src, expect) in [
|
|
("tui.addPreset(1, 'fg=red')", "tui.addPreset: name must be a string (got integer)"),
|
|
("tui.addPreset('2fast', 'fg=red')", "tui.addPreset: name must start with a letter"),
|
|
("tui.addPreset('a=b', 'fg=red')", "tui.addPreset: name must start with a letter"),
|
|
("tui.addPreset('kw', 'fg=notacolor')", "tui.addPreset: invalid style 'fg=notacolor'"),
|
|
("tui.addPreset('kw', 42)", "tui.addPreset: style must be a string (got integer)"),
|
|
] {
|
|
let err = lua.load(src).exec().unwrap_err();
|
|
let msg = err.to_string();
|
|
assert!(msg.contains(expect), "{src}\n expected {expect:?} in: {msg}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_styled_rejects_non_string() {
|
|
let lua = lua();
|
|
let err = lua.load(r#"return tui.styled(42)"#).eval::<String>().unwrap_err();
|
|
assert!(err.to_string().contains("tui.styled:"), "{err}");
|
|
}
|
|
|
|
#[test]
|
|
fn test_styled_action_tags_are_consumed() {
|
|
let lua = lua();
|
|
// Valid action tags are consumed in both color modes (bytes are pinned in
|
|
// stdlib/tui/markup.rs); invalid ones stay literal.
|
|
let out: String = lua
|
|
.load(r#"return tui.styled("a<up=3><clear=line>b <notanop=1> c")"#)
|
|
.eval()
|
|
.unwrap();
|
|
assert!(!out.contains("up=3") && !out.contains("clear"), "{out:?}");
|
|
assert!(out.contains("<notanop=1>"), "invalid tag should stay literal: {out:?}");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// prompt argument validation (fires before any terminal interaction)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Run a prompt call and assert it fails with a message carrying `expect`.
|
|
async fn assert_prompt_error(src: &str, expect: &str) {
|
|
let lua = lua();
|
|
let err = lua.load(src).exec_async().await.unwrap_err();
|
|
let msg = err.to_string();
|
|
assert!(msg.contains(expect), "{src}\n expected {expect:?} in: {msg}");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_prompt_text_arg_validation() {
|
|
assert_prompt_error("tui.confirm(42)", "tui.confirm: prompt text must be a string (got integer)")
|
|
.await;
|
|
assert_prompt_error("tui.confirm('q', 'yes')", "tui.confirm: default must be a boolean").await;
|
|
assert_prompt_error(
|
|
"tui.promptText('q', 42)",
|
|
"tui.promptText: default must be a string (got integer)",
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_unknown_and_mistyped_options() {
|
|
assert_prompt_error(
|
|
"tui.promptText('q', nil, {placholder='x'})",
|
|
"tui.promptText: unknown option 'placholder' (allowed: ",
|
|
)
|
|
.await;
|
|
assert_prompt_error(
|
|
"tui.confirm('q', nil, {help=42})",
|
|
"tui.confirm: option 'help' must be a string (got integer)",
|
|
)
|
|
.await;
|
|
assert_prompt_error(
|
|
"tui.promptText('q', nil, {maxLen='five'})",
|
|
"tui.promptText: option 'maxLen' must be a non-negative integer",
|
|
)
|
|
.await;
|
|
assert_prompt_error(
|
|
"tui.promptText('q', nil, {minLen=5, maxLen=2})",
|
|
"tui.promptText: min (5) must not be greater than max (2)",
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_select_validation() {
|
|
assert_prompt_error(
|
|
"tui.promptSelect('q')",
|
|
"tui.promptSelect: opts table with 'options' is required",
|
|
)
|
|
.await;
|
|
assert_prompt_error(
|
|
"tui.promptSelect('q', nil, {})",
|
|
"tui.promptSelect: opts table with 'options' is required",
|
|
)
|
|
.await;
|
|
assert_prompt_error(
|
|
"tui.promptSelect('q', nil, {options={}})",
|
|
"tui.promptSelect: options must not be empty",
|
|
)
|
|
.await;
|
|
assert_prompt_error(
|
|
"tui.promptSelect('q', nil, {options={'a', {}}})",
|
|
"tui.promptSelect: options[2] must be a string (got table)",
|
|
)
|
|
.await;
|
|
assert_prompt_error(
|
|
"tui.promptSelect('q', 'nope', {options={'a','b'}})",
|
|
"tui.promptSelect: default 'nope' is not one of the options",
|
|
)
|
|
.await;
|
|
assert_prompt_error(
|
|
"tui.promptSelect('q', {'a'}, {options={'a','b'}})",
|
|
"tui.promptSelect: default must be a string (got table)",
|
|
)
|
|
.await;
|
|
assert_prompt_error(
|
|
"tui.promptSelect('q', {'a','nope'}, {options={'a','b'}, multiple=true})",
|
|
"tui.promptSelect: default 'nope' is not one of the options",
|
|
)
|
|
.await;
|
|
assert_prompt_error(
|
|
"tui.promptSelect('q', nil, {options={'a'}, minSelected=1})",
|
|
"tui.promptSelect: option 'minSelected' requires multiple = true",
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_password_and_number_validation() {
|
|
assert_prompt_error(
|
|
"tui.promptSecret('q', {display='partial'})",
|
|
"tui.promptSecret: option 'display' must be one of 'masked', 'hidden', 'full' (got 'partial')",
|
|
)
|
|
.await;
|
|
assert_prompt_error(
|
|
"tui.promptNumber('q', 'five')",
|
|
"tui.promptNumber: default must be a number (got string)",
|
|
)
|
|
.await;
|
|
assert_prompt_error(
|
|
"tui.promptNumber('q', nil, {min=10, max=1})",
|
|
"tui.promptNumber: min (10) must not be greater than max (1)",
|
|
)
|
|
.await;
|
|
assert_prompt_error(
|
|
"tui.promptInt('q', 1.5)",
|
|
"tui.promptInt: default must be an integer (got 1.5)",
|
|
)
|
|
.await;
|
|
assert_prompt_error(
|
|
"tui.promptInt('q', nil, {min=1.5})",
|
|
"tui.promptInt: option 'min' must be an integer (got 1.5)",
|
|
)
|
|
.await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_date_validation() {
|
|
assert_prompt_error(
|
|
"tui.promptDate('q', 'tomorrow')",
|
|
"tui.promptDate: default must be a YYYY-MM-DD string (got 'tomorrow')",
|
|
)
|
|
.await;
|
|
assert_prompt_error(
|
|
"tui.promptDate('q', nil, {min='2026-02-30'})",
|
|
"tui.promptDate: option 'min' must be a YYYY-MM-DD string",
|
|
)
|
|
.await;
|
|
assert_prompt_error(
|
|
"tui.promptDate('q', nil, {min='2026-06-01', max='2026-01-01'})",
|
|
"tui.promptDate: min (2026-06-01) must not be greater than max (2026-01-01)",
|
|
)
|
|
.await;
|
|
assert_prompt_error(
|
|
"tui.promptDate('q', nil, {weekStart='someday'})",
|
|
"tui.promptDate: option 'weekStart' must be a weekday name like 'monday' (got 'someday')",
|
|
)
|
|
.await;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// blocks / raw / screenInfo
|
|
// ---------------------------------------------------------------------------
|
|
// The block functions print to stdout; their rendering is pinned by unit
|
|
// tests in stdlib/tui/blocks.rs. Here we exercise argument validation and
|
|
// the informational surface.
|
|
|
|
#[test]
|
|
fn test_block_validation() {
|
|
let lua = lua();
|
|
for (src, expect) in [
|
|
("tui.success(42)", "tui.success: message must be a string or an array of strings (got integer)"),
|
|
("tui.block({})", "tui.block: message must not be empty"),
|
|
("tui.block({'a', 1})", "tui.block: message[2] must be a string (got integer)"),
|
|
("tui.block('x', {style='notastyle'})", "tui.block: invalid style 'notastyle'"),
|
|
("tui.outlineBlock('x', {label='X'})", "tui.outlineBlock: unknown option 'label'"),
|
|
("tui.note('x', {title='X'})", "tui.note: unknown option 'title'"),
|
|
] {
|
|
let err = lua.load(src).exec().unwrap_err();
|
|
let msg = err.to_string();
|
|
assert!(msg.contains(expect), "{src}\n expected {expect:?} in: {msg}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_block_functions_print_without_error() {
|
|
let lua = lua();
|
|
// Not asserting on stdout (captured by the test harness); this pins that
|
|
// valid calls succeed for both shapes and with option overrides.
|
|
lua.load(
|
|
r#"
|
|
tui.success("all good")
|
|
tui.block({"one", "two"}, {label="HI", style="fg=blue", prefix="> ", padding=true})
|
|
tui.outlineError("boom")
|
|
tui.outlineBlock("custom", {title="Box", style="gray;bold", padding=false})
|
|
tui.raw("progress ", 42, "\r")
|
|
"#,
|
|
)
|
|
.exec()
|
|
.unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn test_screen_info_shape() {
|
|
let lua = lua();
|
|
let (tty, colors, width_type): (bool, bool, String) = lua
|
|
.load(
|
|
r#"
|
|
local i = tui.screenInfo()
|
|
return i.tty, i.colors, type(i.width)
|
|
"#,
|
|
)
|
|
.eval()
|
|
.unwrap();
|
|
// Under cargo test stdout is not a terminal; just pin the field types.
|
|
let _ = (tty, colors);
|
|
assert!(width_type == "number" || width_type == "nil", "width: {width_type}");
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// terminal control (tui.moveTo & co.)
|
|
// ---------------------------------------------------------------------------
|
|
// Argument validation fires before the tty check, so these are deterministic
|
|
// under the test harness. The emitted bytes are pinned by the unit tests in
|
|
// stdlib/tui/term.rs and markup.rs.
|
|
|
|
#[test]
|
|
fn test_term_control_validation() {
|
|
let lua = lua();
|
|
for (src, expect) in [
|
|
("tui.moveTo()", "tui.moveTo: row and col cannot both be nil"),
|
|
("tui.moveTo(0, 1)", "tui.moveTo: row must be a positive integer (got 0)"),
|
|
("tui.moveTo(nil, 'x')", "tui.moveTo: col must be a positive integer (got string)"),
|
|
("tui.move('x')", "tui.move: dx must be an integer (got string)"),
|
|
("tui.move(1, 2.5)", "tui.move: dy must be an integer (got 2.5)"),
|
|
("tui.clearLine(2)", "tui.clearLine: mode must be -1 (to start), 0 (whole, default) or 1 (to end), got 2"),
|
|
("tui.clearScreen(1.5)", "tui.clearScreen: mode must be"),
|
|
("tui.altScreen()", "tui.altScreen: expected a boolean (got nil)"),
|
|
("tui.altScreen(1)", "tui.altScreen: expected a boolean (got integer)"),
|
|
("tui.showCursor('yes')", "tui.showCursor: expected a boolean or nil (got string)"),
|
|
("tui.cursorStyle('blocky')", "tui.cursorStyle: style must be 'block', 'underline' or 'bar' (got 'blocky')"),
|
|
("tui.cursorStyle('bar', {blnk=true})", "tui.cursorStyle: unknown option 'blnk'"),
|
|
("tui.cursorStyle(nil, {blink=true})", "tui.cursorStyle: a style is required when 'blink' is set"),
|
|
("tui.scrollRegion(5)", "tui.scrollRegion: top and bottom must both be given"),
|
|
("tui.scrollRegion(5, 2)", "tui.scrollRegion: top (5) must be less than bottom (2)"),
|
|
("tui.scrollRegion(0, 5)", "tui.scrollRegion: top must be a positive integer (got 0)"),
|
|
("tui.scroll()", "tui.scroll: expected an integer (got nil)"),
|
|
("tui.setTitle(42)", "tui.setTitle: title must be a string (got integer)"),
|
|
("tui.setTitle('a\\nb')", "tui.setTitle: title must not contain control characters"),
|
|
("tui.setClipboard(42)", "tui.setClipboard: text must be a string (got integer)"),
|
|
] {
|
|
let err = lua.load(src).exec().unwrap_err();
|
|
let msg = err.to_string();
|
|
assert!(msg.contains(expect), "{src}\n expected {expect:?} in: {msg}");
|
|
}
|
|
}
|
|
|
|
/// Off-tty every control function validates and then does nothing — no error,
|
|
/// no control bytes in piped output. Skipped when the harness's stdout is a
|
|
/// terminal (the calls would then really move the cursor around the runner).
|
|
#[test]
|
|
fn test_term_control_off_tty_is_noop() {
|
|
use std::io::IsTerminal;
|
|
if std::io::stdout().is_terminal() {
|
|
return;
|
|
}
|
|
let lua = lua();
|
|
lua.load(
|
|
r#"
|
|
tui.moveTo(1, 1); tui.moveTo(2); tui.moveTo(nil, 3)
|
|
tui.move(2, -1); tui.move(0, 0); tui.move()
|
|
tui.saveCursor(); tui.restoreCursor()
|
|
tui.showCursor(false); tui.showCursor()
|
|
tui.cursorStyle("bar", {blink=true}); tui.cursorStyle()
|
|
tui.clearLine(); tui.clearLine(-1); tui.clearLine(1)
|
|
tui.clearScreen(); tui.clearScreen(-1); tui.clearScreen(1)
|
|
tui.altScreen(true); tui.altScreen(false)
|
|
tui.scrollRegion(2, 10); tui.scrollRegion()
|
|
tui.scroll(3); tui.scroll(-2); tui.scroll(0)
|
|
tui.setTitle("test"); tui.setTitle("")
|
|
tui.setClipboard("clip")
|
|
tui.reset()
|
|
"#,
|
|
)
|
|
.exec()
|
|
.unwrap();
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_cursor_pos_off_tty_is_nil() {
|
|
use std::io::IsTerminal;
|
|
if std::io::stdout().is_terminal() {
|
|
return;
|
|
}
|
|
let lua = lua();
|
|
let (row, col): (Option<u32>, Option<u32>) =
|
|
lua.load("return tui.cursorPos()").eval_async().await.unwrap();
|
|
assert_eq!((row, col), (None, None));
|
|
}
|
|
|
|
/// Without a terminal, an actual prompt attempt must fail — with the
|
|
/// function's error prefix, not a panic. The exact wording (NotTTY vs an IO
|
|
/// error) varies by environment, so only the prefix is asserted.
|
|
///
|
|
/// Skipped when `cargo test` has any way to reach a terminal: the prompt
|
|
/// would then really render (garbling the runner output with raw-mode
|
|
/// artifacts) and hang waiting for a key. That includes redirected runs in an
|
|
/// interactive shell — crossterm falls back to `/dev/tty` when stdin is not a
|
|
/// terminal. Only truly terminal-less environments (CI) exercise this.
|
|
#[tokio::test]
|
|
async fn test_prompting_off_tty_errors_with_prefix() {
|
|
use std::io::IsTerminal;
|
|
let has_tty = std::io::stdin().is_terminal()
|
|
|| std::io::stderr().is_terminal()
|
|
|| std::fs::File::open("/dev/tty").is_ok();
|
|
if has_tty {
|
|
return;
|
|
}
|
|
let lua = lua();
|
|
let err = lua.load("tui.confirm('proceed?')").exec_async().await.unwrap_err();
|
|
assert!(err.to_string().contains("tui.confirm:"), "{err}");
|
|
}
|
|
|