Files
zhila/crates/zhila-core/src/lib.rs
T

1094 lines
33 KiB
Rust

//! ZHILA session core: state, journal, validation, and the commands that the
//! hand, the API and MCP will all share.
//!
//! The engine is a trait here so the core can be tested without an audio
//! device. The real engine lives in `zhila-engine`.
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use zhila_protocol::{
Actor, AudioInfo, Command, CommandResult, CoreSummary, Event, FaceRef, LOOP_BARS, LOOP_STEPS,
MAX_BPM, MAX_GAIN, MIN_BPM, MIN_GAIN, MeterFrame, Pattern, PatternRow, STEPS_PER_BAR, State,
TransportState, VEIN_COUNT, VeinId, VeinState,
};
pub const INITIAL_GAINS: [f32; VEIN_COUNT] = [0.82, 0.72, 0.76, 0.62, 0.66];
/// A face as the engine needs it: half-open sample range in the core's source
/// rate.
#[derive(Debug, Clone)]
pub struct AudioFace {
pub index: u8,
pub start: u32,
pub end: u32,
pub rms: f32,
}
/// A core with its samples. The archive keeps this; the engine receives a
/// snapshot of it through the control layer.
#[derive(Clone)]
pub struct Core {
pub summary: CoreSummary,
pub samples: Arc<[f32]>,
pub faces: Vec<AudioFace>,
}
#[derive(Clone)]
pub struct AudioCore {
pub id: String,
pub sample_rate: u32,
pub samples: Arc<[f32]>,
pub faces: Vec<AudioFace>,
}
/// Immutable material view shared with the audio thread.
#[derive(Clone)]
pub struct MaterialSnapshot {
pub pattern: Pattern,
pub cores: Vec<AudioCore>,
}
/// A complete project as loaded from the store.
#[derive(Clone)]
pub struct ProjectData {
pub name: String,
pub revision: u64,
pub bpm: f32,
pub gains: [f32; VEIN_COUNT],
pub pattern: Pattern,
pub cores: Vec<Core>,
pub journal: Vec<JournalEntry>,
pub seq: u64,
}
#[derive(Clone)]
struct ProjectEdit {
bpm: f32,
gains: [f32; VEIN_COUNT],
pattern: Pattern,
}
const MAX_HISTORY: usize = 64;
/// The audio engine as seen by the session. Implementations must be real-time
/// safe on the audio thread and non-blocking on these methods.
pub trait Engine: Send + Sync {
fn play(&self);
fn stop(&self);
fn strike(&self, vein: VeinId, velocity: f32);
fn strike_face(&self, core: &str, face: u8, velocity: f32);
fn set_tempo(&self, bpm: f32);
fn set_gain(&self, vein: VeinId, gain: f32);
fn set_material(&self, material: Arc<MaterialSnapshot>);
fn info(&self) -> AudioInfo;
fn meter(&self) -> MeterFrame;
}
#[derive(Debug, Error)]
pub enum CoreError {
#[error("tempo must be between 40 and 220 bpm")]
InvalidTempo,
#[error("velocity must be between 0 and 1")]
InvalidVelocity,
#[error("gain must be between 0 and 1.2")]
InvalidGain,
#[error("pattern row must be 0..4")]
InvalidRow,
#[error("step must be 0..15")]
InvalidStep,
#[error("no such core or face")]
UnknownFace,
#[error("this command only exists on the binary import path")]
UnsupportedCommand,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct JournalEntry {
pub seq: u64,
pub revision: u64,
pub actor: Actor,
pub at_ms: u64,
pub command: Command,
}
pub struct Session {
revision: u64,
seq: u64,
bpm: f32,
playing: bool,
gains: [f32; VEIN_COUNT],
pattern: Pattern,
cores: Vec<Core>,
journal: Vec<JournalEntry>,
project_name: String,
history: Vec<ProjectEdit>,
redo: Vec<ProjectEdit>,
engine: Arc<dyn Engine>,
}
impl Session {
pub fn new(engine: Arc<dyn Engine>) -> Self {
Self::with_pattern(engine, default_pattern())
}
pub fn with_pattern(engine: Arc<dyn Engine>, pattern: Pattern) -> Self {
let session = Self {
revision: 0,
seq: 0,
bpm: 128.0,
playing: false,
gains: INITIAL_GAINS,
pattern,
cores: Vec::new(),
journal: Vec::new(),
project_name: "descent-04".into(),
history: Vec::new(),
redo: Vec::new(),
engine,
};
session.sync_material();
session
}
pub fn from_project(engine: Arc<dyn Engine>, data: ProjectData) -> Self {
let mut session = Self {
revision: data.revision,
seq: data.seq,
bpm: data.bpm,
playing: false,
gains: data.gains,
pattern: data.pattern,
cores: data.cores,
journal: data.journal,
project_name: data.name,
history: Vec::new(),
redo: Vec::new(),
engine,
};
session.sanitize_faces();
session.sync_material();
session
}
pub fn project_name(&self) -> &str {
&self.project_name
}
pub fn set_project_name(&mut self, name: String) {
self.project_name = name;
}
pub fn can_undo(&self) -> bool {
!self.history.is_empty()
}
pub fn can_redo(&self) -> bool {
!self.redo.is_empty()
}
pub fn bpm(&self) -> f32 {
self.bpm
}
pub fn gains(&self) -> [f32; VEIN_COUNT] {
self.gains
}
fn sanitize_faces(&mut self) {
for row in &mut self.pattern.rows {
let Some(face) = &row.face else { continue };
let exists = self.cores.iter().any(|core| {
core.summary.id == face.core
&& core
.faces
.iter()
.any(|candidate| candidate.index == face.face)
});
if !exists {
row.face = None;
}
}
}
fn edit_snapshot(&self) -> ProjectEdit {
ProjectEdit {
bpm: self.bpm,
gains: self.gains,
pattern: self.pattern.clone(),
}
}
fn push_history(&mut self) {
if self.history.len() >= MAX_HISTORY {
self.history.remove(0);
}
self.history.push(self.edit_snapshot());
self.redo.clear();
}
fn apply_edit(&mut self, edit: ProjectEdit) {
self.bpm = edit.bpm;
self.gains = edit.gains;
self.pattern = edit.pattern;
self.engine.set_tempo(self.bpm);
for (index, gain) in self.gains.iter().enumerate() {
if let Some(vein) = VeinId::from_index(index) {
self.engine.set_gain(vein, *gain);
}
}
self.sync_material();
}
pub fn revision(&self) -> u64 {
self.revision
}
pub fn pattern(&self) -> &Pattern {
&self.pattern
}
pub fn journal(&self) -> &[JournalEntry] {
&self.journal
}
pub fn snapshot(&self) -> State {
let meter = self.engine.meter();
State {
revision: self.revision,
project_name: self.project_name.clone(),
can_undo: self.can_undo(),
can_redo: self.can_redo(),
transport: TransportState {
playing: self.playing,
bpm: self.bpm,
bar: meter.bar,
loop_bars: LOOP_BARS,
},
veins: VeinId::ALL
.iter()
.enumerate()
.map(|(index, vein)| VeinState {
id: *vein,
name: vein.name().to_string(),
gain: self.gains[index],
})
.collect(),
pattern: self.pattern.clone(),
cores: self.cores.iter().map(|core| core.summary.clone()).collect(),
audio: self.engine.info(),
journal_len: self.journal.len(),
}
}
pub fn apply(
&mut self,
command: Command,
actor: Actor,
) -> Result<(CommandResult, Vec<Event>), CoreError> {
match command {
Command::GetSnapshot => Ok((
CommandResult::Snapshot {
state: self.snapshot(),
},
Vec::new(),
)),
Command::Play => {
if self.playing {
return Ok((
CommandResult::State {
state: self.snapshot(),
},
Vec::new(),
));
}
self.playing = true;
self.engine.play();
self.revision += 1;
Ok((
CommandResult::State {
state: self.snapshot(),
},
vec![Event::Transport { playing: true }],
))
}
Command::Stop => {
if !self.playing {
return Ok((
CommandResult::State {
state: self.snapshot(),
},
Vec::new(),
));
}
self.playing = false;
self.engine.stop();
self.revision += 1;
Ok((
CommandResult::State {
state: self.snapshot(),
},
vec![Event::Transport { playing: false }],
))
}
Command::Strike { vein, velocity } => {
if !velocity.is_finite() || !(0.0..=1.0).contains(&velocity) {
return Err(CoreError::InvalidVelocity);
}
self.engine.strike(vein, velocity);
Ok((
CommandResult::State {
state: self.snapshot(),
},
vec![Event::Struck { vein, velocity }],
))
}
Command::SetTempo { bpm } => {
if !bpm.is_finite() || !(MIN_BPM..=MAX_BPM).contains(&bpm) {
return Err(CoreError::InvalidTempo);
}
if (self.bpm - bpm).abs() < 1e-3 {
return Ok((
CommandResult::State {
state: self.snapshot(),
},
Vec::new(),
));
}
self.push_history();
self.bpm = bpm;
self.engine.set_tempo(bpm);
self.revision += 1;
self.record(actor, Command::SetTempo { bpm });
Ok((
CommandResult::State {
state: self.snapshot(),
},
vec![Event::Tempo { bpm }],
))
}
Command::SetVeinGain { vein, gain } => {
if !gain.is_finite() || !(MIN_GAIN..=MAX_GAIN).contains(&gain) {
return Err(CoreError::InvalidGain);
}
let index = vein.index();
if (self.gains[index] - gain).abs() < 1e-3 {
return Ok((
CommandResult::State {
state: self.snapshot(),
},
Vec::new(),
));
}
self.push_history();
self.gains[index] = gain;
self.engine.set_gain(vein, gain);
self.revision += 1;
self.record(actor, Command::SetVeinGain { vein, gain });
Ok((
CommandResult::State {
state: self.snapshot(),
},
vec![Event::VeinGain { vein, gain }],
))
}
Command::StrikeFace {
core,
face,
velocity,
} => {
if !velocity.is_finite() || !(0.0..=1.0).contains(&velocity) {
return Err(CoreError::InvalidVelocity);
}
self.find_face(&core, face)?;
self.engine.strike_face(&core, face, velocity);
Ok((
CommandResult::State {
state: self.snapshot(),
},
vec![Event::FaceStruck {
core,
face,
velocity,
}],
))
}
Command::SetPatternStep {
row,
step,
velocity,
} => self.set_pattern_step(row, step, velocity, actor),
Command::SetPatternFace { row, core, face } => {
self.set_pattern_face(row, &core, face, actor)
}
Command::Undo => {
let Some(edit) = self.history.pop() else {
return Ok((
CommandResult::State {
state: self.snapshot(),
},
Vec::new(),
));
};
self.redo.push(self.edit_snapshot());
self.apply_edit(edit);
self.revision += 1;
self.record(actor, Command::Undo);
Ok((
CommandResult::State {
state: self.snapshot(),
},
vec![Event::History {
can_undo: self.can_undo(),
can_redo: self.can_redo(),
}],
))
}
Command::Redo => {
let Some(edit) = self.redo.pop() else {
return Ok((
CommandResult::State {
state: self.snapshot(),
},
Vec::new(),
));
};
self.history.push(self.edit_snapshot());
self.apply_edit(edit);
self.revision += 1;
self.record(actor, Command::Redo);
Ok((
CommandResult::State {
state: self.snapshot(),
},
vec![Event::History {
can_undo: self.can_undo(),
can_redo: self.can_redo(),
}],
))
}
Command::ImportCore { .. } => Err(CoreError::UnsupportedCommand),
}
}
pub fn cores(&self) -> &[Core] {
&self.cores
}
pub fn material_snapshot(&self) -> MaterialSnapshot {
MaterialSnapshot {
pattern: self.pattern.clone(),
cores: self
.cores
.iter()
.map(|core| AudioCore {
id: core.summary.id.clone(),
sample_rate: core.summary.sample_rate,
samples: core.samples.clone(),
faces: core.faces.clone(),
})
.collect(),
}
}
/// Called by the daemon after the binary import and the analysis.
pub fn import_core(&mut self, core: Core, actor: Actor) -> State {
let name = core.summary.name.clone();
let seal = core.summary.seal;
self.cores.push(core);
self.revision += 1;
self.record(actor, Command::ImportCore { name, seal });
self.sync_material();
self.snapshot()
}
pub fn set_pattern_step(
&mut self,
row: u8,
step: u8,
velocity: u8,
actor: Actor,
) -> Result<(CommandResult, Vec<Event>), CoreError> {
if row as usize >= self.pattern.rows.len() {
return Err(CoreError::InvalidRow);
}
if step as usize >= LOOP_STEPS {
return Err(CoreError::InvalidStep);
}
let velocity = velocity.min(127);
let unchanged = self
.pattern
.rows
.get(row as usize)
.is_some_and(|row| row.velocities[step as usize] == velocity);
if unchanged {
return Ok((
CommandResult::State {
state: self.snapshot(),
},
Vec::new(),
));
}
self.push_history();
let Some(row_ref) = self.pattern.rows.get_mut(row as usize) else {
return Err(CoreError::InvalidRow);
};
row_ref.velocities[step as usize] = velocity;
self.revision += 1;
self.record(
actor,
Command::SetPatternStep {
row,
step,
velocity,
},
);
self.sync_material();
Ok((
CommandResult::State {
state: self.snapshot(),
},
vec![Event::PatternStep {
row,
step,
velocity,
}],
))
}
pub fn set_pattern_face(
&mut self,
row: u8,
core: &str,
face: u8,
actor: Actor,
) -> Result<(CommandResult, Vec<Event>), CoreError> {
if row as usize >= self.pattern.rows.len() {
return Err(CoreError::InvalidRow);
}
self.find_face(core, face)?;
let reference = FaceRef {
core: core.to_string(),
face,
};
let unchanged = self
.pattern
.rows
.get(row as usize)
.and_then(|row| row.face.as_ref())
.is_some_and(|face| face == &reference);
if unchanged {
return Ok((
CommandResult::State {
state: self.snapshot(),
},
Vec::new(),
));
}
self.push_history();
let Some(row_ref) = self.pattern.rows.get_mut(row as usize) else {
return Err(CoreError::InvalidRow);
};
row_ref.face = Some(reference);
self.revision += 1;
self.record(
actor,
Command::SetPatternFace {
row,
core: core.to_string(),
face,
},
);
self.sync_material();
Ok((
CommandResult::State {
state: self.snapshot(),
},
vec![Event::PatternFace {
row,
core: core.to_string(),
face,
}],
))
}
fn find_face(&self, core_id: &str, face: u8) -> Result<(), CoreError> {
let Some(core) = self
.cores
.iter()
.find(|candidate| candidate.summary.id.as_str() == core_id)
else {
return Err(CoreError::UnknownFace);
};
if core.faces.iter().any(|candidate| candidate.index == face) {
Ok(())
} else {
Err(CoreError::UnknownFace)
}
}
fn sync_material(&self) {
let snapshot = self.material_snapshot();
self.engine.set_material(Arc::new(snapshot));
}
fn record(&mut self, actor: Actor, command: Command) {
self.seq += 1;
self.journal.push(JournalEntry {
seq: self.seq,
revision: self.revision,
actor,
at_ms: now_ms(),
command,
});
}
}
fn now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis() as u64)
.unwrap_or_default()
}
/// The first descent: a two-bar phrase in five veins.
pub fn default_pattern() -> Pattern {
let mut rows = Vec::with_capacity(VEIN_COUNT);
for vein in VeinId::ALL {
let mut velocities = vec![0u8; LOOP_STEPS];
match vein {
VeinId::Bass => {
velocities[0] = 112;
velocities[3] = 86;
velocities[6] = 102;
velocities[10] = 94;
velocities[14] = 68;
}
VeinId::Crack => {
velocities[2] = 72;
velocities[7] = 108;
velocities[9] = 58;
velocities[15] = 92;
}
VeinId::Voice => {
velocities[0] = 64;
velocities[8] = 84;
}
VeinId::Noise => {
velocities[4] = 54;
velocities[11] = 62;
velocities[13] = 42;
}
VeinId::Silt => {
velocities[3] = 76;
velocities[13] = 60;
}
}
rows.push(PatternRow {
vein,
face: None,
velocities,
});
}
Pattern {
steps: LOOP_STEPS as u16,
steps_per_bar: STEPS_PER_BAR as u16,
rows,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
use zhila_protocol::{CoreSummary, FaceSummary, Seal};
struct TestEngine {
calls: Mutex<Vec<String>>,
info: AudioInfo,
meter: MeterFrame,
}
impl TestEngine {
fn new() -> Self {
Self {
calls: Mutex::new(Vec::new()),
info: AudioInfo {
running: true,
device: "test".into(),
sample_rate: 48_000,
buffer_frames: 256,
channels: 2,
},
meter: MeterFrame {
bar: 2.25,
master: 0.3,
veins: [0.1, 0.2, 0.3, 0.4, 0.5],
},
}
}
fn calls(&self) -> Vec<String> {
self.calls.lock().unwrap().clone()
}
fn clear(&self) {
self.calls.lock().unwrap().clear();
}
}
impl Engine for TestEngine {
fn play(&self) {
self.calls.lock().unwrap().push("play".into());
}
fn stop(&self) {
self.calls.lock().unwrap().push("stop".into());
}
fn strike(&self, vein: VeinId, velocity: f32) {
self.calls
.lock()
.unwrap()
.push(format!("strike:{}:{velocity}", vein.name()));
}
fn strike_face(&self, core: &str, face: u8, velocity: f32) {
self.calls
.lock()
.unwrap()
.push(format!("face:{core}:{face}:{velocity}"));
}
fn set_tempo(&self, bpm: f32) {
self.calls.lock().unwrap().push(format!("tempo:{bpm}"));
}
fn set_gain(&self, vein: VeinId, gain: f32) {
self.calls
.lock()
.unwrap()
.push(format!("gain:{}:{gain}", vein.name()));
}
fn set_material(&self, material: Arc<MaterialSnapshot>) {
self.calls
.lock()
.unwrap()
.push(format!("material:{}", material.cores.len()));
}
fn info(&self) -> AudioInfo {
self.info.clone()
}
fn meter(&self) -> MeterFrame {
self.meter
}
}
fn session() -> (Session, Arc<TestEngine>) {
let engine = Arc::new(TestEngine::new());
let session = Session::new(engine.clone());
engine.clear();
(session, engine)
}
fn test_core() -> Core {
let faces: Vec<AudioFace> = (0..16)
.map(|index| AudioFace {
index,
start: index as u32 * 3_000,
end: (index as u32 + 1) * 3_000,
rms: 0.5,
})
.collect();
let summary = CoreSummary {
id: "core-test".into(),
name: "test material".into(),
seal: Seal::Found,
sample_rate: 48_000,
duration_sec: 1.0,
faces: faces
.iter()
.map(|face| FaceSummary {
index: face.index,
rank: face.index,
start_sec: face.start as f32 / 48_000.0,
end_sec: face.end as f32 / 48_000.0,
rms: face.rms,
})
.collect(),
peaks: vec![0.0, 0.5, 1.0],
};
Core {
summary,
samples: Arc::from(vec![0.0f32; 48_000]),
faces,
}
}
#[test]
fn default_pattern_is_complete() {
let pattern = default_pattern();
assert_eq!(pattern.steps as usize, LOOP_STEPS);
assert_eq!(pattern.rows.len(), VEIN_COUNT);
for row in &pattern.rows {
assert_eq!(row.velocities.len(), LOOP_STEPS);
assert!(row.velocities.iter().any(|v| *v > 0));
}
}
#[test]
fn play_updates_state_without_journaling() {
let (mut session, engine) = session();
let (result, events) = session.apply(Command::Play, Actor::Hand).unwrap();
let CommandResult::State { state } = result else {
panic!("expected state");
};
assert!(state.transport.playing);
assert_eq!(state.revision, 1);
assert_eq!(session.journal().len(), 0);
assert_eq!(engine.calls(), vec!["play"]);
assert_eq!(events, vec![Event::Transport { playing: true }]);
}
#[test]
fn tempo_is_validated_and_journaled() {
let (mut session, engine) = session();
let (result, events) = session
.apply(Command::SetTempo { bpm: 132.0 }, Actor::Hand)
.unwrap();
let CommandResult::State { state } = result else {
panic!("expected state");
};
assert_eq!(state.transport.bpm, 132.0);
assert_eq!(session.journal().len(), 1);
assert_eq!(session.journal()[0].actor, Actor::Hand);
assert_eq!(session.journal()[0].revision, 1);
assert_eq!(events, vec![Event::Tempo { bpm: 132.0 }]);
assert_eq!(engine.calls(), vec!["tempo:132"]);
}
#[test]
fn tempo_out_of_range_is_rejected() {
let (mut session, engine) = session();
assert!(matches!(
session.apply(Command::SetTempo { bpm: 12.0 }, Actor::Hand),
Err(CoreError::InvalidTempo)
));
assert!(engine.calls().is_empty());
assert_eq!(session.journal().len(), 0);
}
#[test]
fn strikes_are_performance_not_history() {
let (mut session, engine) = session();
let (result, events) = session
.apply(
Command::Strike {
vein: VeinId::Bass,
velocity: 0.8,
},
Actor::Hand,
)
.unwrap();
let CommandResult::State { state } = result else {
panic!("expected state");
};
assert_eq!(state.revision, 0);
assert_eq!(session.journal().len(), 0);
assert_eq!(events.len(), 1);
assert_eq!(engine.calls()[0], "strike:bass:0.8");
}
#[test]
fn gain_is_validated() {
let (mut session, _) = session();
assert!(matches!(
session.apply(
Command::SetVeinGain {
vein: VeinId::Noise,
gain: 2.0
},
Actor::Api
),
Err(CoreError::InvalidGain)
));
session
.apply(
Command::SetVeinGain {
vein: VeinId::Noise,
gain: 0.55,
},
Actor::Api,
)
.unwrap();
assert_eq!(session.journal().len(), 1);
assert_eq!(session.journal()[0].actor, Actor::Api);
}
#[test]
fn snapshot_carries_meter_and_pattern() {
let (session, _) = session();
let state = session.snapshot();
assert_eq!(state.transport.bar, 2.25);
assert_eq!(state.transport.loop_bars, LOOP_BARS);
assert_eq!(state.veins.len(), VEIN_COUNT);
assert_eq!(state.pattern.rows.len(), VEIN_COUNT);
assert_eq!(state.audio.device, "test");
assert_eq!(state.journal_len, 0);
assert!(state.cores.is_empty());
}
#[test]
fn import_adds_a_core_and_syncs_material() {
let (mut session, engine) = session();
let state = session.import_core(test_core(), Actor::Hand);
assert_eq!(state.cores.len(), 1);
assert_eq!(state.cores[0].id, "core-test");
assert_eq!(session.journal().len(), 1);
assert!(matches!(
session.journal()[0].command,
Command::ImportCore { .. }
));
assert!(engine.calls().iter().any(|call| call == "material:1"));
}
#[test]
fn pattern_edits_are_journaled_and_synced() {
let (mut session, engine) = session();
session.import_core(test_core(), Actor::Hand);
engine.clear();
let (result, events) = session
.set_pattern_face(0, "core-test", 3, Actor::Hand)
.unwrap();
let CommandResult::State { state } = result else {
panic!("expected state");
};
assert_eq!(state.pattern.rows[0].face.as_ref().unwrap().face, 3);
assert_eq!(
events,
vec![Event::PatternFace {
row: 0,
core: "core-test".into(),
face: 3
}]
);
let (result, events) = session.set_pattern_step(0, 1, 100, Actor::Hand).unwrap();
let CommandResult::State { state } = result else {
panic!("expected state");
};
assert_eq!(state.pattern.rows[0].velocities[1], 100);
assert_eq!(
events,
vec![Event::PatternStep {
row: 0,
step: 1,
velocity: 100
}]
);
assert_eq!(session.journal().len(), 3);
assert!(
engine
.calls()
.iter()
.any(|call| call.starts_with("material:"))
);
}
#[test]
fn undo_and_redo_project_edits() {
let (mut session, _) = session();
session
.apply(Command::SetTempo { bpm: 132.0 }, Actor::Hand)
.unwrap();
session.set_pattern_step(0, 0, 0, Actor::Hand).unwrap();
assert!(session.can_undo());
assert!(!session.can_redo());
let (result, events) = session.apply(Command::Undo, Actor::Hand).unwrap();
let CommandResult::State { state } = result else {
panic!("expected state");
};
assert_eq!(state.pattern.rows[0].velocities[0], 112);
assert_eq!(state.transport.bpm, 132.0);
assert!(state.can_redo);
assert_eq!(
events,
vec![Event::History {
can_undo: true,
can_redo: true
}]
);
let (result, _) = session.apply(Command::Undo, Actor::Hand).unwrap();
let CommandResult::State { state } = result else {
panic!("expected state");
};
assert_eq!(state.transport.bpm, 128.0);
assert!(!state.can_undo);
let (result, _) = session.apply(Command::Redo, Actor::Hand).unwrap();
let CommandResult::State { state } = result else {
panic!("expected state");
};
assert_eq!(state.transport.bpm, 132.0);
assert!(session.journal().len() >= 4);
}
#[test]
fn project_data_loads_cores_pattern_and_name() {
let engine = Arc::new(TestEngine::new());
let session = Session::from_project(
engine.clone(),
ProjectData {
name: "loaded project".into(),
revision: 7,
bpm: 120.0,
gains: [0.5; VEIN_COUNT],
pattern: default_pattern(),
cores: vec![test_core()],
journal: Vec::new(),
seq: 0,
},
);
engine.clear();
let state = session.snapshot();
assert_eq!(state.project_name, "loaded project");
assert_eq!(state.transport.bpm, 120.0);
assert_eq!(state.veins[0].gain, 0.5);
assert_eq!(state.cores.len(), 1);
assert_eq!(state.journal_len, 0);
assert!(!state.can_undo);
}
#[test]
fn faces_are_validated_and_can_be_struck() {
let (mut session, engine) = session();
session.import_core(test_core(), Actor::Hand);
engine.clear();
let (result, events) = session
.apply(
Command::StrikeFace {
core: "core-test".into(),
face: 5,
velocity: 0.75,
},
Actor::Hand,
)
.unwrap();
let CommandResult::State { state } = result else {
panic!("expected state");
};
assert_eq!(state.cores.len(), 1);
assert_eq!(events.len(), 1);
assert_eq!(engine.calls(), vec!["face:core-test:5:0.75"]);
assert!(matches!(
session.apply(
Command::StrikeFace {
core: "missing".into(),
face: 0,
velocity: 0.5
},
Actor::Hand
),
Err(CoreError::UnknownFace)
));
}
}