//! 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}"); }