Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 165 additions & 0 deletions src/manifest/expand_test_cases/property_cases.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
//! Property-based tests for manifest `foreach`/`when` expansion invariants.
//!
//! These complement the example-based cases in `condition_cases.rs` by
//! checking the expansion contract across generated inputs: entry counts,
//! index sequencing, `when` determinism, whitespace-only `when` rejection,
//! and `foreach` key removal.

use super::*;
use minijinja::Environment;
use proptest::prelude::*;
use serde_json::json;

/// Build a single-section manifest document iterating over `items`.
fn foreach_doc(section: &str, items: &[String], when: Option<&str>) -> ManifestValue {
let mut entry = json!({
"name": "literal",
"command": "echo hi",
"foreach": items,
});
if let Some(expr) = when
&& let Some(map) = entry.as_object_mut()
{
map.insert("when".into(), json!(expr));
}
let mut doc = ManifestMap::new();
doc.insert(section.into(), json!([entry]));
ManifestValue::Object(doc)
}

/// Strategy producing short lowercase item names.
fn item_names(max: usize) -> impl Strategy<Value = Vec<String>> {
proptest::collection::vec("[a-z]{1,8}", 0..=max)
}

/// Strategy producing items drawn from a keep/skip alphabet so `when`
/// expressions filter a meaningful subset.
fn keep_skip_items(max: usize) -> impl Strategy<Value = Vec<String>> {
proptest::collection::vec(
prop_oneof![Just("keep".to_owned()), Just("skip".to_owned())],
0..=max,
)
}

fn expanded_entries<'a>(
doc: &'a ManifestValue,
section: &str,
) -> Result<&'a [ManifestValue], TestCaseError> {
doc.get(section)
.and_then(|v| v.as_array())
.map(Vec::as_slice)
.ok_or_else(|| TestCaseError::fail(format!("{section} sequence missing")))
}

proptest! {
/// Absent `when` filtering, a list of `n` items expands to exactly `n`
/// entries.
#[test]
fn expansion_yields_one_entry_per_item(items in item_names(10)) {
let env = Environment::new();
for section in ["targets", "actions"] {
let mut doc = foreach_doc(section, &items, None);
let stats = expand_foreach(&mut doc, &env)
.map_err(|e| TestCaseError::fail(format!("expansion failed: {e}")))?;
prop_assert_eq!(stats.filtered_targets, 0);
prop_assert_eq!(stats.filtered_actions, 0);
let entries = expanded_entries(&doc, section)?;
prop_assert_eq!(entries.len(), items.len());
}
}

/// `index` values across expanded entries are unique and form the
/// sequence `0..n`.
#[test]
fn indexes_are_sequential_and_unique(items in item_names(10)) {
let env = Environment::new();
for section in ["targets", "actions"] {
let mut doc = foreach_doc(section, &items, None);
expand_foreach(&mut doc, &env)
.map_err(|e| TestCaseError::fail(format!("expansion failed: {e}")))?;
let entries = expanded_entries(&doc, section)?;
let mut indexes = Vec::new();
for entry in entries {
let index = entry
.get("vars")
.and_then(|v| v.get("index"))
.and_then(ManifestValue::as_u64)
.ok_or_else(|| TestCaseError::fail("missing numeric index var"))?;
indexes.push(index);
}
let expected: Vec<u64> = (0..items.len() as u64).collect();
prop_assert_eq!(indexes, expected);
}
}

/// No expanded entry retains a `foreach` key.
#[test]
fn foreach_key_is_removed_from_all_entries(items in keep_skip_items(10)) {
let env = Environment::new();
for section in ["targets", "actions"] {
let mut doc = foreach_doc(section, &items, Some("item == 'keep'"));
expand_foreach(&mut doc, &env)
.map_err(|e| TestCaseError::fail(format!("expansion failed: {e}")))?;
for entry in expanded_entries(&doc, section)? {
let map = entry
.as_object()
.ok_or_else(|| TestCaseError::fail("expanded entry is not an object"))?;
prop_assert!(!map.contains_key("foreach"));
prop_assert!(!map.contains_key("when"));
}
}
}

/// Re-evaluating the same `when` expression on the same input always
/// produces the same set of entries.
#[test]
fn when_filtering_is_deterministic(items in keep_skip_items(10)) {
let env = Environment::new();
for section in ["targets", "actions"] {
let mut first = foreach_doc(section, &items, Some("item != 'skip'"));
let mut second = first.clone();
let first_stats = expand_foreach(&mut first, &env)
.map_err(|e| TestCaseError::fail(format!("first expansion failed: {e}")))?;
let second_stats = expand_foreach(&mut second, &env)
.map_err(|e| TestCaseError::fail(format!("second expansion failed: {e}")))?;
prop_assert_eq!(first_stats, second_stats);
prop_assert_eq!(first, second);
}
}

/// Any `when` value composed solely of whitespace characters is rejected,
/// regardless of whitespace kind or length.
#[test]
fn whitespace_only_when_is_rejected(
ws in proptest::collection::vec(
prop::sample::select(vec![
'\u{0009}', '\u{000A}', '\u{000B}', '\u{000C}', '\u{000D}', '\u{0020}',
'\u{0085}', '\u{00A0}', '\u{1680}', '\u{2000}', '\u{2001}', '\u{2002}',
'\u{2003}', '\u{2004}', '\u{2005}', '\u{2006}', '\u{2007}', '\u{2008}',
'\u{2009}', '\u{200A}', '\u{2028}', '\u{2029}', '\u{202F}', '\u{205F}',
'\u{3000}',
]),
1..10,
),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
items in item_names(3),
) {
let env = Environment::new();
let expr: String = ws.into_iter().collect();
for section in ["targets", "actions"] {
let mut doc = foreach_doc(section, &items, Some(&expr));
// The whitespace-only check applies on the iteration path and the
// plain path alike; with zero items the `when` clause is never
// reached, so anchor the non-iterating variant too.
let mut plain_section = ManifestMap::new();
plain_section.insert(
section.into(),
json!([{ "name": "literal", "command": "echo hi", "when": expr.as_str() }]),
);
let mut plain = ManifestValue::Object(plain_section);
prop_assert!(expand_foreach(&mut plain, &env).is_err());
if !items.is_empty() {
prop_assert!(expand_foreach(&mut doc, &env).is_err());
}
}
}
}
3 changes: 3 additions & 0 deletions src/manifest/expand_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ mod a_tracing_capture;
mod action_condition_cases;
#[path = "expand_test_cases/condition_cases.rs"]
mod condition_cases;

#[path = "expand_test_cases/property_cases.rs"]
mod property_cases;
#[path = "expand_test_cases/target_command_available_cases.rs"]
mod target_command_available_cases;

Expand Down
Loading