use super::harness::*; use std::collections::HashMap; fn parse_params(output: &str) -> HashMap { let mut params = HashMap::new(); let parts: Vec<&str> = output.trim_start_matches('/').split('/').collect(); let mut i = 0; while i + 1 < parts.len() { if let Ok(v) = parts[i + 1].parse::() { params.insert(parts[i].to_string(), v); } i += 2; } params } fn get_deltas(outputs: &[String]) -> Vec { outputs .iter() .map(|o| parse_params(o).get("delta").copied().unwrap_or(0.0)) .collect() } fn get_gates(outputs: &[String]) -> Vec { outputs .iter() .map(|o| parse_params(o).get("gate").copied().unwrap_or(0.0)) .collect() } fn get_sounds(outputs: &[String]) -> Vec { outputs .iter() .map(|o| { let parts: Vec<&str> = o.trim_start_matches('/').split('/').collect(); if parts.len() >= 2 && parts[0] == "sound" { parts[1].to_string() } else { String::new() } }) .collect() } const EPSILON: f64 = 1e-9; fn approx_eq(a: f64, b: f64) -> bool { (a - b).abs() < EPSILON } #[test] fn stepdur_baseline() { let f = run("stepdur"); assert!(approx_eq(stack_float(&f), 0.125)); } #[test] fn single_emit() { let outputs = expect_outputs(r#""kick" snd ."#, 1); let deltas = get_deltas(&outputs); assert!(approx_eq(deltas[0], 0.0), "single emit at start should have delta 0"); } #[test] fn multiple_emits_all_at_zero() { let outputs = expect_outputs(r#""kick" snd . . . ."#, 4); let deltas = get_deltas(&outputs); for (i, delta) in deltas.iter().enumerate() { assert!(approx_eq(*delta, 0.0), "emit {}: expected delta 0, got {}", i, delta); } } #[test] fn sound_persists() { let outputs = expect_outputs(r#""kick" snd . . "hat" snd . ."#, 4); let sounds = get_sounds(&outputs); assert_eq!(sounds[0], "kick"); assert_eq!(sounds[1], "kick"); assert_eq!(sounds[2], "hat"); assert_eq!(sounds[3], "hat"); } #[test] fn alternating_sounds() { let outputs = expect_outputs(r#""kick" snd . "snare" snd . "kick" snd . "snare" snd ."#, 4); let sounds = get_sounds(&outputs); assert_eq!(sounds, vec!["kick", "snare", "kick", "snare"]); } #[test] fn gate_is_step_duration() { let outputs = expect_outputs(r#""kick" snd ."#, 1); let gates = get_gates(&outputs); assert!(approx_eq(gates[0], 0.5), "gate should be 4 * step_duration (0.5), got {}", gates[0]); } #[test] fn cycle_picks_by_runs() { for runs in 0..4 { let ctx = ctx_with(|c| c.runs = runs); let f = forth(); let outputs = f.evaluate(r#""kick" snd ( . ) ( ) 2 cycle"#, &ctx).unwrap(); if runs % 2 == 0 { assert_eq!(outputs.len(), 1, "runs={}: emit should be picked", runs); } else { assert_eq!(outputs.len(), 0, "runs={}: no-op should be picked", runs); } } } #[test] fn pcycle_picks_by_iter() { for iter in 0..4 { let ctx = ctx_with(|c| c.iter = iter); let f = forth(); let outputs = f.evaluate(r#""kick" snd ( . ) ( ) 2 pcycle"#, &ctx).unwrap(); if iter % 2 == 0 { assert_eq!(outputs.len(), 1, "iter={}: emit should be picked", iter); } else { assert_eq!(outputs.len(), 0, "iter={}: no-op should be picked", iter); } } } #[test] fn cycle_with_sounds() { for runs in 0..3 { let ctx = ctx_with(|c| c.runs = runs); let f = forth(); let outputs = f.evaluate( r#"( "kick" snd . ) ( "hat" snd . ) ( "snare" snd . ) 3 cycle"#, &ctx ).unwrap(); assert_eq!(outputs.len(), 1, "runs={}: expected 1 output", runs); let sounds = get_sounds(&outputs); let expected = ["kick", "hat", "snare"][runs % 3]; assert_eq!(sounds[0], expected, "runs={}: expected {}", runs, expected); } } #[test] fn at_single_delta() { let outputs = expect_outputs(r#"0.5 at "kick" snd ."#, 1); let deltas = get_deltas(&outputs); let step_dur = 0.125; let sr: f64 = 48000.0; assert!(approx_eq(deltas[0], (0.5 * step_dur * sr).round()), "expected delta at 0.5 of step, got {}", deltas[0]); } #[test] fn at_list_deltas() { let outputs = expect_outputs(r#"0 0.5 at "kick" snd ."#, 2); let deltas = get_deltas(&outputs); let step_dur = 0.125; let sr: f64 = 48000.0; assert!(approx_eq(deltas[0], 0.0), "expected delta 0, got {}", deltas[0]); assert!(approx_eq(deltas[1], (0.5 * step_dur * sr).round()), "expected delta at 0.5 of step, got {}", deltas[1]); } #[test] fn at_three_deltas() { let outputs = expect_outputs(r#"0 0.33 0.67 at "kick" snd ."#, 3); let deltas = get_deltas(&outputs); let step_dur = 0.125; let sr: f64 = 48000.0; assert!(approx_eq(deltas[0], 0.0), "expected delta 0"); assert!(approx_eq(deltas[1], (0.33 * step_dur * sr).round()), "expected delta at 0.33 of step"); assert!(approx_eq(deltas[2], (0.67 * step_dur * sr).round()), "expected delta at 0.67 of step"); } #[test] fn at_persists_across_emits() { // With at as a loop, each at...block is independent. // Two separate at blocks each emit twice. let outputs = expect_outputs(r#"0 0.5 at "kick" snd . 0 0.5 at "hat" snd ."#, 4); let sounds = get_sounds(&outputs); assert_eq!(sounds, vec!["kick", "kick", "hat", "hat"]); } #[test] fn at_reset_with_zero() { let outputs = expect_outputs(r#"0 0.5 at "kick" snd . 0.0 at "hat" snd ."#, 3); let sounds = get_sounds(&outputs); assert_eq!(sounds, vec!["kick", "kick", "hat"]); } #[test] fn clear_resets_at_deltas() { let outputs = expect_outputs(r#"0 0.5 at "kick" snd . clear "hat" snd ."#, 3); let sounds = get_sounds(&outputs); assert_eq!(sounds, vec!["kick", "kick", "hat"]); let deltas = get_deltas(&outputs); assert!(approx_eq(deltas[2], 0.0), "after clear, hat should emit at delta 0, got {}", deltas[2]); } #[test] fn at_records_selected_spans() { use cagire::forth::ExecutionTrace; let f = forth(); let mut trace = ExecutionTrace::default(); let script = r#"0 0.5 0.75 at "kick" snd ."#; f.evaluate_with_trace(script, &default_ctx(), &mut trace).unwrap(); // With at-loop, each iteration emits once: 3 sound spans total assert_eq!(trace.selected_spans.len(), 3, "expected 3 selected spans (1 sound per iteration)"); } fn get_notes(outputs: &[String]) -> Vec { outputs .iter() .map(|o| parse_params(o).get("note").copied().unwrap_or(0.0)) .collect() } #[test] fn at_loop_with_cycle_notes() { // at-loop + cycle replaces at + arp: cycle advances per subdivision let ctx = ctx_with(|c| c.runs = 0); let f = forth(); let outputs = f.evaluate(r#"0 0.25 0.5 0.75 at sine snd [ c4 e4 g4 b4 ] cycle note ."#, &ctx).unwrap(); assert_eq!(outputs.len(), 4); let notes = get_notes(&outputs); assert!(approx_eq(notes[0], 60.0)); assert!(approx_eq(notes[1], 64.0)); assert!(approx_eq(notes[2], 67.0)); assert!(approx_eq(notes[3], 71.0)); let deltas = get_deltas(&outputs); let step_dur = 0.125; let sr: f64 = 48000.0; assert!(approx_eq(deltas[0], 0.0)); assert!(approx_eq(deltas[1], (0.25 * step_dur * sr).round())); assert!(approx_eq(deltas[2], (0.5 * step_dur * sr).round())); assert!(approx_eq(deltas[3], (0.75 * step_dur * sr).round())); } #[test] fn at_loop_cycle_wraps() { // cycle inside at-loop wraps when more deltas than items let ctx = ctx_with(|c| c.runs = 0); let f = forth(); let outputs = f.evaluate(r#"0 0.25 0.5 0.75 at sine snd [ c4 e4 ] cycle note ."#, &ctx).unwrap(); assert_eq!(outputs.len(), 4); let notes = get_notes(&outputs); assert!(approx_eq(notes[0], 60.0)); // idx 0 % 2 = 0 -> c4 assert!(approx_eq(notes[1], 64.0)); // idx 1 % 2 = 1 -> e4 assert!(approx_eq(notes[2], 60.0)); // idx 2 % 2 = 0 -> c4 assert!(approx_eq(notes[3], 64.0)); // idx 3 % 2 = 1 -> e4 } #[test] fn at_loop_rand_different_per_subdivision() { // rand inside at-loop produces different values per iteration let f = forth(); let outputs = f.evaluate(r#"0 0.5 at sine snd 1 1000 rand freq ."#, &default_ctx()).unwrap(); assert_eq!(outputs.len(), 2); let freqs: Vec = outputs.iter() .map(|o| parse_params(o).get("freq").copied().unwrap_or(0.0)) .collect(); assert!(freqs[0] != freqs[1], "rand should produce different values: {} vs {}", freqs[0], freqs[1]); } #[test] fn at_loop_poly_cycling() { // CycleList inside at-loop: poly stacking within each iteration let outputs = expect_outputs(r#"0 0.5 at sine snd c4 e4 note ."#, 4); let notes = get_notes(&outputs); // Each iteration emits both poly voices (c4 and e4) assert!(approx_eq(notes[0], 60.0)); // iter 0, poly 0 assert!(approx_eq(notes[1], 64.0)); // iter 0, poly 1 assert!(approx_eq(notes[2], 60.0)); // iter 1, poly 0 assert!(approx_eq(notes[3], 64.0)); // iter 1, poly 1 } // --- every+ / except+ tests --- #[test] fn every_offset_fires_at_offset() { for iter in 0..8 { let ctx = ctx_with(|c| c.iter = iter); let f = forth(); let outputs = f.evaluate(r#""kick" snd ( . ) 4 2 every+"#, &ctx).unwrap(); if iter % 4 == 2 { assert_eq!(outputs.len(), 1, "iter={}: should fire", iter); } else { assert_eq!(outputs.len(), 0, "iter={}: should not fire", iter); } } } #[test] fn every_offset_wraps_large_offset() { // offset 6 with n=4 → 6 % 4 = 2, same as offset 2 for iter in 0..8 { let ctx = ctx_with(|c| c.iter = iter); let f = forth(); let outputs = f.evaluate(r#""kick" snd ( . ) 4 6 every+"#, &ctx).unwrap(); if iter % 4 == 2 { assert_eq!(outputs.len(), 1, "iter={}: should fire (wrapped offset)", iter); } else { assert_eq!(outputs.len(), 0, "iter={}: should not fire", iter); } } } #[test] fn except_offset_inverse() { for iter in 0..8 { let ctx = ctx_with(|c| c.iter = iter); let f = forth(); let outputs = f.evaluate(r#""kick" snd ( . ) 4 2 except+"#, &ctx).unwrap(); if iter % 4 != 2 { assert_eq!(outputs.len(), 1, "iter={}: should fire", iter); } else { assert_eq!(outputs.len(), 0, "iter={}: should not fire", iter); } } } #[test] fn every_offset_zero_is_same_as_every() { for iter in 0..8 { let ctx = ctx_with(|c| c.iter = iter); let f = forth(); let a = f.evaluate(r#""kick" snd ( . ) 3 every"#, &ctx).unwrap(); let b = f.evaluate(r#""kick" snd ( . ) 3 0 every+"#, &ctx).unwrap(); assert_eq!(a.len(), b.len(), "iter={}: every and every+ 0 should match", iter); } } // --- at-loop feature tests --- #[test] fn at_loop_choose_independent_per_subdivision() { let f = forth(); let outputs = f.evaluate(r#"0 0.5 at sine snd [ 60 64 67 71 ] choose note ."#, &default_ctx()).unwrap(); assert_eq!(outputs.len(), 2); // Both are valid notes from the set (just verify they're within range) let notes = get_notes(&outputs); for n in ¬es { assert!([60.0, 64.0, 67.0, 71.0].contains(n), "unexpected note {n}"); } } #[test] fn at_loop_multiple_blocks_independent() { let outputs = expect_outputs( r#"0 0.5 at "kick" snd . 0 0.25 0.5 at "hat" snd ."#, 5, ); let sounds = get_sounds(&outputs); assert_eq!(sounds[0], "kick"); assert_eq!(sounds[1], "kick"); assert_eq!(sounds[2], "hat"); assert_eq!(sounds[3], "hat"); assert_eq!(sounds[4], "hat"); } #[test] fn at_loop_single_delta_one_iteration() { let outputs = expect_outputs(r#"0.25 at "kick" snd ."#, 1); let sounds = get_sounds(&outputs); assert_eq!(sounds[0], "kick"); let deltas = get_deltas(&outputs); let step_dur = 0.125; let sr: f64 = 48000.0; assert!(approx_eq(deltas[0], (0.25 * step_dur * sr).round())); } #[test] fn at_without_closer_falls_back_to_legacy() { // When at has no matching closer (., m., done), falls back to Op::At let f = forth(); let result = f.evaluate(r#"0 0.5 at "kick" snd"#, &default_ctx()); assert!(result.is_ok()); } #[test] fn at_loop_cycle_advances_across_runs() { // Across different runs values, cycle inside at-loop picks correctly for base_runs in 0..3 { let ctx = ctx_with(|c| c.runs = base_runs); let f = forth(); let outputs = f.evaluate( r#"0 0.5 at sine snd [ c4 e4 g4 ] cycle note ."#, &ctx, ).unwrap(); assert_eq!(outputs.len(), 2); let notes = get_notes(&outputs); // runs for iter i = base_runs * 2 + i let expected_0 = [60.0, 64.0, 67.0][(base_runs * 2) % 3]; let expected_1 = [60.0, 64.0, 67.0][(base_runs * 2 + 1) % 3]; assert!(approx_eq(notes[0], expected_0), "runs={base_runs}: iter 0 expected {expected_0}, got {}", notes[0]); assert!(approx_eq(notes[1], expected_1), "runs={base_runs}: iter 1 expected {expected_1}, got {}", notes[1]); } } #[test] fn at_loop_midi_emit() { let f = forth(); let outputs = f.evaluate("0 0.25 0.5 at 60 note m.", &default_ctx()).unwrap(); assert_eq!(outputs.len(), 3); for o in &outputs { assert!(o.contains("/midi/note/60/")); } // First should have no delta (or delta/0), others should have delta assert!(outputs[1].contains("/delta/")); assert!(outputs[2].contains("/delta/")); } #[test] fn at_loop_done_no_emit() { let f = forth(); let outputs = f.evaluate("0 0.5 at [ 1 2 ] cycle drop done", &default_ctx()).unwrap(); assert!(outputs.is_empty()); } #[test] fn at_loop_done_sets_variables() { let f = forth(); let outputs = f .evaluate("0 0.5 at [ 10 20 ] cycle !x done kick snd @x freq .", &default_ctx()) .unwrap(); assert_eq!(outputs.len(), 1); // Last iteration wins: cycle(1) = 20 let params = parse_params(&outputs[0]); assert!(approx_eq(*params.get("freq").unwrap(), 20.0)); }