Skip to content

Commit 0e3ae8a

Browse files
committed
feat(cli-validation): add 10 aggregate CLI tests (T119–T128)
- T119: agg count — validates Total: line with non-zero count - T120: agg overview — checks count + stats + buckets sections - T121: agg by_extension — verifies common extensions in bucket table - T122: agg by_type — checks type category buckets - T123: agg by_drive — validates drive letter buckets - T124: agg by_size — checks size distribution labels - T125: agg by_age — validates age distribution section - T126: agg count --format json — validates JSON structure - T127: agg overview --format json — checks ≥3 results with correct kinds - T128: agg by_extension --format csv — validates CSV header and data rows Also updates requires_cli() to detect subcommands (agg, aggregate, stats, info, daemon, index) for correct CLI process routing.
1 parent 18ce75f commit 0e3ae8a

File tree

1 file changed

+171
-2
lines changed

1 file changed

+171
-2
lines changed

scripts/windows/cli-flag-validation.rs

Lines changed: 171 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
// WARM — no daemon, cache files exist (daemon auto-starts from cache)
2121
// HOT — daemon already running (pure in-memory search)
2222
//
23-
// Phase 2 — Parallel validation: runs ALL 141 tests concurrently against
23+
// Phase 2 — Parallel validation: runs ALL 153 tests concurrently against
2424
// the HOT daemon from Phase 1.
2525
//
2626
// Usage:
@@ -252,6 +252,13 @@ const CLI_ONLY_FLAGS: &[&str] = &[
252252

253253
/// Returns true if this test's args require spawning a CLI process.
254254
fn requires_cli(args: &[String]) -> bool {
255+
// Subcommands (agg, aggregate, stats, etc.) always need CLI.
256+
if let Some(first) = args.first() {
257+
let f = first.to_lowercase();
258+
if matches!(f.as_str(), "agg" | "aggregate" | "stats" | "info" | "daemon" | "index") {
259+
return true;
260+
}
261+
}
255262
for (i, a) in args.iter().enumerate() {
256263
// --columns all → direct is fine (we output all columns).
257264
// --columns Name,Size,... → needs CLI for column projection.
@@ -2557,6 +2564,168 @@ fn define_tests() -> Vec<TestSpec> {
25572564
Ok(format!("{} dirs, treesize+desc constraints met", rows.len()))
25582565
}));
25592566

2567+
// ═══════════════════════════════════════════════════════════════
2568+
// AGGREGATE TESTS (T119–T133)
2569+
// ═══════════════════════════════════════════════════════════════
2570+
2571+
// ── T119: uffs agg count ─────────────────────────────────────
2572+
specs.push(spec("T119 agg count", &["agg", "count"], |stdout, _| {
2573+
// Expect: "=== total_count ===" and " Total: <number>"
2574+
if !stdout.contains("Total:") {
2575+
bail!("Missing 'Total:' line in count output");
2576+
}
2577+
// Extract the number after "Total:"
2578+
let total_line = stdout.lines()
2579+
.find(|l| l.contains("Total:"))
2580+
.unwrap_or("");
2581+
let num_str: String = total_line.chars()
2582+
.filter(|c| c.is_ascii_digit())
2583+
.collect();
2584+
let total: u64 = num_str.parse().unwrap_or(0);
2585+
if total == 0 { bail!("Total count is 0"); }
2586+
Ok(format!("count = {total}"))
2587+
}));
2588+
2589+
// ── T120: agg overview ───────────────────────────────────────
2590+
specs.push(spec("T120 agg overview", &["agg", "overview"], |stdout, _| {
2591+
// Overview should produce count + stats + buckets sections.
2592+
let has_total = stdout.contains("Total:");
2593+
let has_count = stdout.contains("Count:");
2594+
let has_sum = stdout.contains("Sum:");
2595+
let has_key = stdout.contains("Key");
2596+
if !has_total && !has_count {
2597+
bail!("Overview missing count/total section");
2598+
}
2599+
if !has_sum {
2600+
bail!("Overview missing stats section (no Sum:)");
2601+
}
2602+
if !has_key {
2603+
bail!("Overview missing bucket table (no Key header)");
2604+
}
2605+
Ok("overview: count + stats + buckets present".into())
2606+
}));
2607+
2608+
// ── T121: agg by_extension ───────────────────────────────────
2609+
specs.push(spec("T121 agg by_extension", &["agg", "by_extension"], |stdout, _| {
2610+
// Should have a bucket table with extension keys.
2611+
if !stdout.contains("Key") {
2612+
bail!("Missing bucket table header");
2613+
}
2614+
// Common extensions that should appear on any Windows system.
2615+
let has_some_ext = ["dll", "exe", "sys", "txt", "log", "xml", "json", "ini"]
2616+
.iter()
2617+
.any(|ext| stdout.to_lowercase().contains(ext));
2618+
if !has_some_ext {
2619+
bail!("No common extensions found in by_extension output");
2620+
}
2621+
let bucket_count = stdout.lines()
2622+
.filter(|l| l.trim_start().starts_with(|c: char| c.is_alphanumeric()))
2623+
.filter(|l| l.contains('%'))
2624+
.count();
2625+
Ok(format!("{bucket_count} extension buckets"))
2626+
}));
2627+
2628+
// ── T122: agg by_type ────────────────────────────────────────
2629+
specs.push(spec("T122 agg by_type", &["agg", "by_type"], |stdout, _| {
2630+
if !stdout.contains("Key") {
2631+
bail!("Missing bucket table header");
2632+
}
2633+
// Type categories should include at least one of these common types.
2634+
let types = ["document", "image", "video", "audio", "code",
2635+
"archive", "executable", "system", "other"];
2636+
let has_type = types.iter().any(|t| stdout.to_lowercase().contains(t));
2637+
if !has_type {
2638+
bail!("No type categories found in by_type output");
2639+
}
2640+
Ok("type categories present".into())
2641+
}));
2642+
2643+
// ── T123: agg by_drive ───────────────────────────────────────
2644+
specs.push(spec("T123 agg by_drive", &["agg", "by_drive"], |stdout, _| {
2645+
// Should have at least one drive letter (C:, D:, etc.)
2646+
let has_drive = ('A'..='Z').any(|c| stdout.contains(&format!("{c}:")));
2647+
if !has_drive {
2648+
bail!("No drive letters found in by_drive output");
2649+
}
2650+
Ok("drive buckets present".into())
2651+
}));
2652+
2653+
// ── T124: agg by_size ────────────────────────────────────────
2654+
specs.push(spec("T124 agg by_size", &["agg", "by_size"], |stdout, _| {
2655+
if !stdout.contains("Key") {
2656+
bail!("Missing bucket table header");
2657+
}
2658+
// Size ranges should appear (e.g., "0 B", "KB", "MB", "GB").
2659+
let has_size = ["KB", "MB", "GB", "bytes", "B "]
2660+
.iter()
2661+
.any(|s| stdout.contains(s));
2662+
if !has_size {
2663+
bail!("No size range labels found");
2664+
}
2665+
Ok("size distribution buckets present".into())
2666+
}));
2667+
2668+
// ── T125: agg by_age ─────────────────────────────────────────
2669+
specs.push(spec("T125 agg by_age", &["agg", "by_age"], |stdout, _| {
2670+
if !stdout.contains("Key") && !stdout.contains("===") {
2671+
bail!("Missing section header");
2672+
}
2673+
// Age buckets should have date or time-period labels.
2674+
Ok("age distribution present".into())
2675+
}));
2676+
2677+
// ── T126: agg count --format json ────────────────────────────
2678+
specs.push(spec("T126 agg count --format json",
2679+
&["agg", "count", "--format", "json"], |stdout, _| {
2680+
let parsed: serde_json::Value = serde_json::from_str(stdout.trim())
2681+
.map_err(|e| anyhow::anyhow!("Invalid JSON: {e}"))?;
2682+
let arr = parsed.as_array()
2683+
.ok_or_else(|| anyhow::anyhow!("Expected JSON array"))?;
2684+
if arr.is_empty() { bail!("Empty JSON array"); }
2685+
let first = &arr[0];
2686+
let kind = first.get("kind").and_then(|v| v.as_str()).unwrap_or("");
2687+
if kind != "count" { bail!("Expected kind=count, got {kind}"); }
2688+
let value = first.get("value").and_then(|v| v.as_u64()).unwrap_or(0);
2689+
if value == 0 { bail!("Count value is 0"); }
2690+
Ok(format!("JSON count = {value}"))
2691+
}));
2692+
2693+
// ── T127: agg overview --format json ─────────────────────────
2694+
specs.push(spec("T127 agg overview --format json",
2695+
&["agg", "overview", "--format", "json"], |stdout, _| {
2696+
let parsed: serde_json::Value = serde_json::from_str(stdout.trim())
2697+
.map_err(|e| anyhow::anyhow!("Invalid JSON: {e}"))?;
2698+
let arr = parsed.as_array()
2699+
.ok_or_else(|| anyhow::anyhow!("Expected JSON array"))?;
2700+
if arr.len() < 3 { bail!("Overview should produce ≥3 results, got {}", arr.len()); }
2701+
// Should have count, stats, and buckets kinds.
2702+
let kinds: Vec<&str> = arr.iter()
2703+
.filter_map(|v| v.get("kind").and_then(|k| k.as_str()))
2704+
.collect();
2705+
let has_count = kinds.contains(&"count");
2706+
let has_stats = kinds.contains(&"stats");
2707+
let has_buckets = kinds.contains(&"buckets");
2708+
if !has_count { bail!("Missing count result"); }
2709+
if !has_stats { bail!("Missing stats result"); }
2710+
if !has_buckets { bail!("Missing buckets result"); }
2711+
Ok(format!("{} results: {:?}", arr.len(), kinds))
2712+
}));
2713+
2714+
// ── T128: agg by_extension --format csv ──────────────────────
2715+
specs.push(spec("T128 agg by_extension --format csv",
2716+
&["agg", "by_extension", "--format", "csv"], |stdout, _| {
2717+
let lines: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect();
2718+
if lines.is_empty() { bail!("Empty CSV output"); }
2719+
// First line should be header with key,count,total_bytes,...
2720+
let header = lines[0].to_lowercase();
2721+
if !header.contains("key") || !header.contains("count") {
2722+
bail!("CSV header missing key/count: {}", lines[0]);
2723+
}
2724+
let data_rows = lines.len() - 1;
2725+
if data_rows == 0 { bail!("No data rows in CSV"); }
2726+
Ok(format!("{data_rows} CSV rows"))
2727+
}));
2728+
25602729
specs
25612730
}
25622731

@@ -2614,7 +2783,7 @@ fn main() {
26142783

26152784
if specs.is_empty() {
26162785
eprintln!(" {} No tests matched filter: {:?}", "⚠".yellow(), args.test_filter);
2617-
eprintln!(" Available test IDs: T00, T01, ..., T118");
2786+
eprintln!(" Available test IDs: T00, T01, ..., T128");
26182787
std::process::exit(1);
26192788
}
26202789

0 commit comments

Comments
 (0)