Files
Cagire/tests/forth/temporal.rs

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 &notes {
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));
}