//! 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 b c> d")"#)
.eval()
.unwrap();
// Tags are consumed, payload text survives in order.
assert!(!out.contains(""), "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("\\x y")"#)
.eval()
.unwrap();
assert!(out.contains("x"), "escape not resolved: {out:?}");
assert!(out.contains(""), "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("a> b c d e")"#)
.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("foo bar>")
"#,
)
.eval()
.unwrap();
assert!(!out.contains(""), "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("foo")"#).eval().unwrap();
assert!(out.contains(""), "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::().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("ab c")"#)
.eval()
.unwrap();
assert!(!out.contains("up=3") && !out.contains("clear"), "{out:?}");
assert!(out.contains(""), "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, Option) =
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}");
}