test: Add proc macro DSL for declarative tests.
This commit is contained in:
455
crates/pikl-test-macros/src/codegen.rs
Normal file
455
crates/pikl-test-macros/src/codegen.rs
Normal file
@@ -0,0 +1,455 @@
|
||||
use proc_macro2::{Ident, Span, TokenStream};
|
||||
use quote::quote;
|
||||
|
||||
use crate::parse::{ActionExpr, Fixtures, PiklTests, TestCase, TestKind, TestModule};
|
||||
|
||||
/// Generate all test modules from the parsed DSL.
|
||||
pub fn generate(input: &PiklTests) -> syn::Result<TokenStream> {
|
||||
let mut output = TokenStream::new();
|
||||
for module in &input.modules {
|
||||
output.extend(gen_module(module)?);
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Generate a single `mod name { ... }` block with test
|
||||
/// functions and the right imports for the test kind.
|
||||
fn gen_module(module: &TestModule) -> syn::Result<TokenStream> {
|
||||
let mod_name = &module.name;
|
||||
let mut test_fns = Vec::new();
|
||||
|
||||
for case in &module.tests {
|
||||
let tokens = match module.kind {
|
||||
TestKind::Headless => gen_headless(case, &module.fixtures)?,
|
||||
TestKind::Filter => gen_filter(case, &module.fixtures)?,
|
||||
TestKind::Nav => gen_nav(case, &module.fixtures)?,
|
||||
TestKind::Menu => gen_menu(case, &module.fixtures)?,
|
||||
};
|
||||
test_fns.push(tokens);
|
||||
}
|
||||
|
||||
let imports = gen_imports(module.kind);
|
||||
|
||||
Ok(quote! {
|
||||
mod #mod_name {
|
||||
#imports
|
||||
#(#test_fns)*
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Emit the `use` imports needed for a given test kind.
|
||||
fn gen_imports(kind: TestKind) -> TokenStream {
|
||||
match kind {
|
||||
TestKind::Headless => {
|
||||
quote! { use super::*; }
|
||||
}
|
||||
TestKind::Filter => {
|
||||
quote! {
|
||||
use pikl_core::item::Item;
|
||||
use pikl_core::filter::{Filter, FuzzyFilter};
|
||||
}
|
||||
}
|
||||
TestKind::Nav => {
|
||||
quote! {
|
||||
use pikl_core::navigation::Viewport;
|
||||
}
|
||||
}
|
||||
TestKind::Menu => {
|
||||
quote! {
|
||||
use pikl_core::item::Item;
|
||||
use pikl_core::event::{Action, MenuEvent, MenuResult};
|
||||
use pikl_core::menu::MenuRunner;
|
||||
use pikl_core::json_menu::JsonMenu;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Headless
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Generate a headless integration test that spawns the pikl
|
||||
/// binary, feeds it items and a script, and asserts on
|
||||
/// stdout/stderr/exit code.
|
||||
fn gen_headless(case: &TestCase, fixtures: &Fixtures) -> syn::Result<TokenStream> {
|
||||
let test_name = &case.name;
|
||||
|
||||
// Build items string: "item1\nitem2\n"
|
||||
let items_str = match &fixtures.items {
|
||||
Some(items) => {
|
||||
let mut s = String::new();
|
||||
for item in items {
|
||||
s.push_str(item);
|
||||
s.push('\n');
|
||||
}
|
||||
s
|
||||
}
|
||||
None => String::new(),
|
||||
};
|
||||
|
||||
// Build script string from actions
|
||||
let script = build_headless_script(&case.actions);
|
||||
|
||||
// Build extra CLI args (e.g. --label-key)
|
||||
let extra_args: Vec<TokenStream> = if let Some(ref key) = fixtures.label_key {
|
||||
vec![quote! { "--label-key" }, quote! { #key }]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
// Build assertions
|
||||
let mut asserts = Vec::new();
|
||||
|
||||
if let Some(exit) = case.exit_code {
|
||||
asserts.push(quote! {
|
||||
assert_eq!(
|
||||
code, #exit,
|
||||
"expected exit {}, got {}, stderr: {}", #exit, code, stderr
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(ref expected) = case.stdout {
|
||||
if expected.is_empty() {
|
||||
asserts.push(quote! {
|
||||
assert!(
|
||||
stdout.trim().is_empty(),
|
||||
"expected empty stdout, got: {}", stdout
|
||||
);
|
||||
});
|
||||
} else {
|
||||
asserts.push(quote! {
|
||||
assert!(
|
||||
stdout.contains(#expected),
|
||||
"expected stdout to contain {:?}, got: {}", #expected, stdout
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref expected) = case.stderr_contains {
|
||||
asserts.push(quote! {
|
||||
assert!(
|
||||
stderr.contains(#expected),
|
||||
"expected stderr to contain {:?}, got: {}", #expected, stderr
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Ok(quote! {
|
||||
#[test]
|
||||
fn #test_name() {
|
||||
let (stdout, stderr, code) = run_pikl(#items_str, #script, &[#(#extra_args),*]);
|
||||
#(#asserts)*
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Turn action expressions into the newline-delimited script
|
||||
/// text that gets piped to `--action-fd`.
|
||||
fn build_headless_script(actions: &[ActionExpr]) -> String {
|
||||
let mut script = String::new();
|
||||
for action in actions {
|
||||
match action {
|
||||
ActionExpr::Simple(name) => {
|
||||
script.push_str(name);
|
||||
script.push('\n');
|
||||
}
|
||||
ActionExpr::Filter(query) => {
|
||||
script.push_str("filter ");
|
||||
script.push_str(query);
|
||||
script.push('\n');
|
||||
}
|
||||
ActionExpr::Raw(line) => {
|
||||
script.push_str(line);
|
||||
script.push('\n');
|
||||
}
|
||||
ActionExpr::AddItems(_) => {
|
||||
// Not applicable for headless. Items come from stdin.
|
||||
}
|
||||
}
|
||||
}
|
||||
script
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filter
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Generate a filter unit test: create items, push them
|
||||
/// into a `FuzzyFilter`, set the query, and assert on
|
||||
/// matched labels.
|
||||
fn gen_filter(case: &TestCase, fixtures: &Fixtures) -> syn::Result<TokenStream> {
|
||||
let test_name = &case.name;
|
||||
|
||||
let item_exprs = gen_item_constructors(fixtures);
|
||||
|
||||
let query = case.query.as_deref().unwrap_or("");
|
||||
|
||||
let mut asserts = Vec::new();
|
||||
|
||||
if let Some(ref expected) = case.match_labels {
|
||||
asserts.push(quote! {
|
||||
let labels: Vec<&str> = (0..f.matched_count())
|
||||
.filter_map(|i| f.matched_index(i))
|
||||
.map(|idx| items[idx].label())
|
||||
.collect();
|
||||
let expected: Vec<&str> = vec![#(#expected),*];
|
||||
assert_eq!(
|
||||
labels, expected,
|
||||
"query {:?}: expected {:?}, got {:?}", #query, expected, labels
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Ok(quote! {
|
||||
#[test]
|
||||
fn #test_name() {
|
||||
let items: Vec<Item> = vec![#(#item_exprs),*];
|
||||
let mut f = FuzzyFilter::new();
|
||||
for (i, item) in items.iter().enumerate() {
|
||||
f.push(i, item.label());
|
||||
}
|
||||
f.set_query(#query);
|
||||
#(#asserts)*
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Nav
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Generate a navigation unit test: create a viewport, run
|
||||
/// movement actions, and assert on cursor/offset.
|
||||
fn gen_nav(case: &TestCase, fixtures: &Fixtures) -> syn::Result<TokenStream> {
|
||||
let test_name = &case.name;
|
||||
|
||||
let (height, count) = fixtures.viewport.unwrap_or((10, 20));
|
||||
let height_lit = height;
|
||||
let count_lit = count;
|
||||
|
||||
let action_calls = gen_nav_actions(&case.actions)?;
|
||||
|
||||
let mut asserts = Vec::new();
|
||||
|
||||
if let Some(cursor) = case.cursor {
|
||||
asserts.push(quote! {
|
||||
assert_eq!(
|
||||
v.cursor(), #cursor,
|
||||
"expected cursor {}, got {}", #cursor, v.cursor()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(offset) = case.offset {
|
||||
asserts.push(quote! {
|
||||
assert_eq!(
|
||||
v.scroll_offset(), #offset,
|
||||
"expected offset {}, got {}", #offset, v.scroll_offset()
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Ok(quote! {
|
||||
#[test]
|
||||
fn #test_name() {
|
||||
let mut v = Viewport::new();
|
||||
v.set_height(#height_lit);
|
||||
v.set_filtered_count(#count_lit);
|
||||
#(#action_calls)*
|
||||
#(#asserts)*
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert DSL action names to `Viewport` method calls
|
||||
/// (e.g. `move-down` becomes `v.move_down()`).
|
||||
fn gen_nav_actions(actions: &[ActionExpr]) -> syn::Result<Vec<TokenStream>> {
|
||||
let mut calls = Vec::new();
|
||||
for action in actions {
|
||||
match action {
|
||||
ActionExpr::Simple(name) => {
|
||||
let method = Ident::new(&name.replace('-', "_"), Span::call_site());
|
||||
let needs_count = matches!(
|
||||
name.as_str(),
|
||||
"move-up" | "move-down" | "page-up" | "page-down"
|
||||
);
|
||||
if needs_count {
|
||||
calls.push(quote! { v.#method(1); });
|
||||
} else {
|
||||
calls.push(quote! { v.#method(); });
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
return Err(syn::Error::new(
|
||||
Span::call_site(),
|
||||
format!(
|
||||
"nav tests only support simple actions, got: {:?}",
|
||||
action_debug(action)
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(calls)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Menu
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Generate an async menu state machine test: create a menu,
|
||||
/// send actions, and assert on the final result (selected
|
||||
/// item or cancellation).
|
||||
fn gen_menu(case: &TestCase, fixtures: &Fixtures) -> syn::Result<TokenStream> {
|
||||
let test_name = &case.name;
|
||||
let item_exprs = gen_item_constructors(fixtures);
|
||||
|
||||
let label_key = fixtures.label_key.as_deref().unwrap_or("label");
|
||||
|
||||
let action_sends = gen_menu_actions(&case.actions)?;
|
||||
|
||||
let result_assert = if case.cancelled {
|
||||
quote! {
|
||||
assert!(
|
||||
matches!(result, Ok(MenuResult::Cancelled)),
|
||||
"expected Cancelled, got: {:?}", result.as_ref().map(|r| format!("{:?}", r))
|
||||
);
|
||||
}
|
||||
} else if let Some(ref expected) = case.selected {
|
||||
quote! {
|
||||
match &result {
|
||||
Ok(MenuResult::Selected(value)) => {
|
||||
let got = value.as_str()
|
||||
.or_else(|| value.get(#label_key).and_then(|v| v.as_str()))
|
||||
.unwrap_or("");
|
||||
assert_eq!(
|
||||
got, #expected,
|
||||
"expected selected {:?}, got value: {:?}", #expected, value
|
||||
);
|
||||
}
|
||||
other => panic!("expected Selected, got: {:?}", other),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No assertion on result. Probably an error, but let it compile.
|
||||
quote! {}
|
||||
};
|
||||
|
||||
// If test expects cancellation via sender drop (no cancel action, no confirm),
|
||||
// we need to drop tx after sending actions.
|
||||
let drop_sender = quote! { drop(tx); };
|
||||
|
||||
Ok(quote! {
|
||||
#[tokio::test]
|
||||
async fn #test_name() {
|
||||
let items = vec![#(#item_exprs),*];
|
||||
let (menu, tx) = MenuRunner::new(JsonMenu::new(items, #label_key.to_string()));
|
||||
let mut rx = menu.subscribe();
|
||||
let handle = tokio::spawn(async move { menu.run().await });
|
||||
|
||||
// Wait for initial state broadcast.
|
||||
let _ = rx.recv().await;
|
||||
|
||||
// Give the viewport some height so confirms work.
|
||||
let _ = tx.send(Action::Resize { height: 50 }).await;
|
||||
let _ = rx.recv().await;
|
||||
|
||||
// Send all actions.
|
||||
#(#action_sends)*
|
||||
|
||||
// Drop sender so menu loop can exit.
|
||||
#drop_sender
|
||||
|
||||
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
|
||||
#result_assert
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert DSL actions to `tx.send(Action::...)` calls for
|
||||
/// menu tests.
|
||||
fn gen_menu_actions(actions: &[ActionExpr]) -> syn::Result<Vec<TokenStream>> {
|
||||
let mut sends = Vec::new();
|
||||
for action in actions {
|
||||
let expr = match action {
|
||||
ActionExpr::Simple(name) => {
|
||||
let variant = menu_action_variant(name)?;
|
||||
quote! { let _ = tx.send(#variant).await; }
|
||||
}
|
||||
ActionExpr::Filter(query) => {
|
||||
quote! {
|
||||
let _ = tx.send(Action::UpdateFilter(#query.to_string())).await;
|
||||
}
|
||||
}
|
||||
ActionExpr::AddItems(items) => {
|
||||
let item_exprs: Vec<TokenStream> = items
|
||||
.iter()
|
||||
.map(|s| quote! { serde_json::Value::String(#s.to_string()) })
|
||||
.collect();
|
||||
quote! {
|
||||
let _ = tx.send(Action::AddItems(vec![#(#item_exprs),*])).await;
|
||||
}
|
||||
}
|
||||
ActionExpr::Raw(_) => {
|
||||
return Err(syn::Error::new(
|
||||
Span::call_site(),
|
||||
"raw actions are only supported in headless tests",
|
||||
));
|
||||
}
|
||||
};
|
||||
sends.push(expr);
|
||||
}
|
||||
Ok(sends)
|
||||
}
|
||||
|
||||
/// Map a DSL action name like `"move-down"` to the
|
||||
/// corresponding `Action::MoveDown` token stream.
|
||||
fn menu_action_variant(name: &str) -> syn::Result<TokenStream> {
|
||||
let tokens = match name {
|
||||
"confirm" => quote! { Action::Confirm },
|
||||
"cancel" => quote! { Action::Cancel },
|
||||
"move-down" => quote! { Action::MoveDown(1) },
|
||||
"move-up" => quote! { Action::MoveUp(1) },
|
||||
"move-to-top" => quote! { Action::MoveToTop },
|
||||
"move-to-bottom" => quote! { Action::MoveToBottom },
|
||||
"page-up" => quote! { Action::PageUp(1) },
|
||||
"page-down" => quote! { Action::PageDown(1) },
|
||||
_ => {
|
||||
return Err(syn::Error::new(
|
||||
Span::call_site(),
|
||||
format!("unknown menu action: '{name}'"),
|
||||
));
|
||||
}
|
||||
};
|
||||
Ok(tokens)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Generate `Item::from_plain_text("...")` expressions for
|
||||
/// each item in the fixtures.
|
||||
fn gen_item_constructors(fixtures: &Fixtures) -> Vec<TokenStream> {
|
||||
match &fixtures.items {
|
||||
Some(items) => items
|
||||
.iter()
|
||||
.map(|s| quote! { Item::from_plain_text(#s) })
|
||||
.collect(),
|
||||
None => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Format an action expression for use in error messages.
|
||||
fn action_debug(action: &ActionExpr) -> String {
|
||||
match action {
|
||||
ActionExpr::Simple(name) => name.clone(),
|
||||
ActionExpr::Filter(q) => format!("filter \"{q}\""),
|
||||
ActionExpr::Raw(r) => format!("raw \"{r}\""),
|
||||
ActionExpr::AddItems(items) => format!("add-items {:?}", items),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user