431 lines
14 KiB
Rust
431 lines
14 KiB
Rust
use super::harness::*;
|
|
use std::collections::HashMap;
|
|
|
|
fn parse_params(output: &str) -> HashMap<String, f64> {
|
|
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::<f64>() {
|
|
params.insert(parts[i].to_string(), v);
|
|
}
|
|
i += 2;
|
|
}
|
|
params
|
|
}
|
|
|
|
fn get_deltas(outputs: &[String]) -> Vec<f64> {
|
|
outputs
|
|
.iter()
|
|
.map(|o| parse_params(o).get("delta").copied().unwrap_or(0.0))
|
|
.collect()
|
|
}
|
|
|
|
fn get_gates(outputs: &[String]) -> Vec<f64> {
|
|
outputs
|
|
.iter()
|
|
.map(|o| parse_params(o).get("gate").copied().unwrap_or(0.0))
|
|
.collect()
|
|
}
|
|
|
|
fn get_sounds(outputs: &[String]) -> Vec<String> {
|
|
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<f64> {
|
|
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<f64> = 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));
|
|
}
|