From febfe3a7dae1b80b571679e66024226870804c7e Mon Sep 17 00:00:00 2001 From: thacer13 Date: Mon, 9 Nov 2026 16:42:32 -0300 Subject: [PATCH 1/6] Clocks and seeds a test can fix: interaction timers on the motions' clock, one seed for random colours and the randomizers The drag, double-click, hold and tap timers read anim::now instead of Instant::now, and the random colours, the piano roll's randomizer and the clip colours take their seed from clip_colors::clock; a project made new in a test that runs the motions' clock starts at a fixed date. Nothing changes outside tests: anim::now is the time unless a test sets the clock. For the equivalence harness (specs roadmap/control-plane.md part 2, step 0), which needs every run alike. Co-Authored-By: Claude Opus 5.5 --- crates/os-app/src/app.rs | 14 +++++++------- crates/os-app/src/browser.rs | 2 +- crates/os-app/src/clip_colors.rs | 4 +++- crates/os-app/src/filedlg.rs | 4 ++-- crates/os-app/src/hold.rs | 2 +- crates/os-app/src/mixer.rs | 2 +- crates/os-app/src/patterns.rs | 2 +- crates/os-app/src/piano.rs | 2 +- crates/os-app/src/playlist.rs | 6 +++--- crates/os-app/src/project_info.rs | 26 ++++++++++++++++++-------- crates/os-app/src/rack.rs | 2 +- crates/os-app/src/remote.rs | 4 ++-- crates/os-app/src/smooth.rs | 2 +- crates/os-app/src/song_pos.rs | 2 +- crates/os-app/src/tapper.rs | 4 ++-- crates/os-app/src/views/browser.rs | 2 +- 16 files changed, 46 insertions(+), 34 deletions(-) diff --git a/crates/os-app/src/app.rs b/crates/os-app/src/app.rs index dcd97d3a5..a21a32dfe 100644 --- a/crates/os-app/src/app.rs +++ b/crates/os-app/src/app.rs @@ -3651,7 +3651,7 @@ impl App { PerfAct::List => {} PerfAct::Row(i) => { let Some(key) = p.shown().get(i).map(|r| r.key) else { return }; - let now = std::time::Instant::now(); + let now = crate::anim::now(); let double = p.last_click.is_some_and(|(k, t)| k == key && now.duration_since(t).as_millis() < 400); p.last_click = if double { None } else { Some((key, now)) }; if double { @@ -3953,7 +3953,7 @@ impl App { fn tap(&mut self, t: f64) { let tp = &mut self.state.tapper; let bpm = tp.count.tap(t); - tp.flash_at = Some(std::time::Instant::now()); + tp.flash_at = Some(crate::anim::now()); tp.flash = 1.0; let accent = tp.count.bar().is_some_and(|(_, accent)| accent); #[cfg(test)] @@ -4300,7 +4300,7 @@ impl App { self.update_tick(); // CPU panel (spec: ui/main-window.md §13) and the Plugin performance monitor: a reading at the panel's refresh // rate. - let now = std::time::Instant::now(); + let now = crate::anim::now(); self.song_pos_tick(now); self.hold_tick(now); self.ease_tick(now); @@ -4777,7 +4777,7 @@ impl App { /// A press on the piano roll's splitter: drags it; a double click hides / brings back the event panel (spec: /// ui/event-panel-splitter.md §2). fn piano_split_press(&mut self) { - let now = std::time::Instant::now(); + let now = crate::anim::now(); let p = &mut self.state.piano; let double = p.split_click.is_some_and(|t| now.duration_since(t).as_millis() < 400); p.split_click = if double { None } else { Some(now) }; @@ -8769,7 +8769,7 @@ impl App { Action::StepEdit => self.toggle_step_edit(), // spec: ui/main-window.md §13: a double click opens the Plugin performance monitor; one click does nothing. Action::CpuPanel => { - let now = std::time::Instant::now(); + let now = crate::anim::now(); let double = self.cpu_click.is_some_and(|t| now.duration_since(t).as_millis() < 400); self.cpu_click = if double { None } else { Some(now) }; if double { @@ -9067,7 +9067,7 @@ impl App { | Action::RackFill(_) => {} Action::WinDrag(win) => { // Double-click on the title bar toggles maximized / floating (like the max button). - let now = std::time::Instant::now(); + let now = crate::anim::now(); let m = self.mouse; let double = matches!(self.title_click, Some((w, t, p)) if w == win && now.duration_since(t).as_millis() < 500 @@ -9323,7 +9323,7 @@ impl App { fn rack_act(&mut self, a: &Action) -> bool { use crate::rack as rk; let (shift, ctrl, alt) = (self.shift, self.ctrl, self.alt); - let now = std::time::Instant::now(); + let now = crate::anim::now(); let double = matches!(&self.rack_click, Some((p, t)) if p == a && now.duration_since(*t).as_millis() < 400); if matches!(a, Action::RackSelect(_) | Action::RackMixer(_)) { self.rack_click = if double { None } else { Some((a.clone(), now)) }; diff --git a/crates/os-app/src/browser.rs b/crates/os-app/src/browser.rs index 723014aad..ccc0eb378 100644 --- a/crates/os-app/src/browser.rs +++ b/crates/os-app/src/browser.rs @@ -417,7 +417,7 @@ impl App { /// A press on the browser's splitter: drags it; a double click gives the browser its default width (spec §9). fn browser_split_press(&mut self) { - let now = std::time::Instant::now(); + let now = crate::anim::now(); let s = &mut self.state; let double = s.browser_split_click.is_some_and(|t| now.duration_since(t).as_millis() < 400); s.browser_split_click = if double { None } else { Some(now) }; diff --git a/crates/os-app/src/clip_colors.rs b/crates/os-app/src/clip_colors.rs index 2de9276e0..55967aac0 100644 --- a/crates/os-app/src/clip_colors.rs +++ b/crates/os-app/src/clip_colors.rs @@ -64,7 +64,9 @@ impl ClipColors { } } -fn clock() -> u64 { +/// The seed of every random colour and the piano roll's randomizer: the time, and a fixed one in tests so a test +/// (and the equivalence harness) gets the same colours on every run. +pub(crate) fn clock() -> u64 { if cfg!(test) { return 0x5eed; } diff --git a/crates/os-app/src/filedlg.rs b/crates/os-app/src/filedlg.rs index 0fea7c02e..cd13a7351 100644 --- a/crates/os-app/src/filedlg.rs +++ b/crates/os-app/src/filedlg.rs @@ -402,8 +402,8 @@ impl FileDlg { /// A click on row `i`: selects it; the second click of a double click opens a folder or chooses a file. pub fn click_row(&mut self, i: usize) -> Outcome { - let double = self.last_click.is_some_and(|(r, t)| r == i && t.elapsed() < DOUBLE); - self.last_click = if double { None } else { Some((i, Instant::now())) }; + let double = self.last_click.is_some_and(|(r, t)| r == i && crate::anim::now().duration_since(t) < DOUBLE); + self.last_click = if double { None } else { Some((i, crate::anim::now())) }; self.select(i); if double { self.activate(i) } else { Outcome::Stay } } diff --git a/crates/os-app/src/hold.rs b/crates/os-app/src/hold.rs index c150fea2a..9cf4b6028 100644 --- a/crates/os-app/src/hold.rs +++ b/crates/os-app/src/hold.rs @@ -92,7 +92,7 @@ impl NoteType { impl App { /// A press just started a drag of `kind`'s sort: with the check on, it is held from now. pub(crate) fn hold_start(&mut self, kind: Kind) { - self.hold = prefs::check(&prefs::CLICK_AND_HOLD).then(|| Hold { at: Instant::now(), from: self.mouse, kind, periods: 0 }); + self.hold = prefs::check(&prefs::CLICK_AND_HOLD).then(|| Hold { at: crate::anim::now(), from: self.mouse, kind, periods: 0 }); } /// The mouse moved to (`x`, `y`): further than [`SLOP`] from the press, the hold ends and the drag goes on as one. diff --git a/crates/os-app/src/mixer.rs b/crates/os-app/src/mixer.rs index 3bc8ab325..736d15e29 100644 --- a/crates/os-app/src/mixer.rs +++ b/crates/os-app/src/mixer.rs @@ -2386,7 +2386,7 @@ fn targets(s: &State, n: u16) -> Vec { } fn rand_seed() -> u32 { - std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(7, |d| d.subsec_nanos()) + crate::clip_colors::clock() as u32 } /// Keys while the mixer is in front (§2): F2 rename, Del delete, Alt+Left / Right move, S solo, Alt+S alt solo, diff --git a/crates/os-app/src/patterns.rs b/crates/os-app/src/patterns.rs index 784944a2c..7e5eb0e4c 100644 --- a/crates/os-app/src/patterns.rs +++ b/crates/os-app/src/patterns.rs @@ -348,7 +348,7 @@ fn run_now(s: &mut State, cmd: Cmd, at: (f32, f32)) -> Option { } } Cmd::RandomColor => { - let seed = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos() as u64); + let seed = crate::clip_colors::clock(); pat_mut(s, cur).color = Some(random_color(seed ^ u64::from(cur))); s.piano.dirty = true; } diff --git a/crates/os-app/src/piano.rs b/crates/os-app/src/piano.rs index bdec5a2c5..a6124ccea 100644 --- a/crates/os-app/src/piano.rs +++ b/crates/os-app/src/piano.rs @@ -142,7 +142,7 @@ impl Default for PianoState { range: None, row_h: ROW, ghosts: true, - rng: std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(1, |d| d.as_nanos() as u64) | 1, + rng: crate::clip_colors::clock() | 1, split: crate::piano_split::load(), split_click: None, was_shown: None, diff --git a/crates/os-app/src/playlist.rs b/crates/os-app/src/playlist.rs index 13d35bb74..966804fae 100644 --- a/crates/os-app/src/playlist.rs +++ b/crates/os-app/src/playlist.rs @@ -3338,7 +3338,7 @@ pub fn press_grid(app: &mut App, grid: Rect, right: bool) { if !right && matches!(tool, PlTool::Draw | PlTool::Paint | PlTool::Select) { let audio = audio_clip_row(s, &c); if audio.is_some() || matches!(c.source, ClipSource::Pattern(_)) { - let now = std::time::Instant::now(); + let now = crate::anim::now(); let double = s.playlist.last_clip.is_some_and(|(i, t)| i == ci && now.duration_since(t).as_millis() < 450); s.playlist.last_clip = (!double).then_some((ci, now)); let opens = audio.map(|_| crate::shortcuts::clip_double_click(alt)); @@ -4108,7 +4108,7 @@ pub fn press_ruler(app: &mut App, grid: Rect, right: bool) { app.drag = Some(Drag::Pl(PlDrag::TimeSel { t0: t })); } else { // A double click clears the time selection (a Lemon Studio choice, besides FL's right click). - let now = std::time::Instant::now(); + let now = crate::anim::now(); let double = s.playlist.last_ruler.is_some_and(|t| now.duration_since(t).as_millis() < 450); s.playlist.last_ruler = (!double).then_some(now); if double { @@ -4197,7 +4197,7 @@ pub fn press_header(app: &mut App, t: u16, right: bool, at: (f32, f32)) { crate::perform::stop(&mut app.state, t, playing, app.alt); } let s = &mut app.state; - let now = std::time::Instant::now(); + let now = crate::anim::now(); let double = s.playlist.last_header.is_some_and(|(lt, when)| lt == t && now.duration_since(when).as_millis() < 450); s.playlist.track = t; s.playlist.last_header = Some((t, now)); diff --git a/crates/os-app/src/project_info.rs b/crates/os-app/src/project_info.rs index bb883b3d9..01814f311 100644 --- a/crates/os-app/src/project_info.rs +++ b/crates/os-app/src/project_info.rs @@ -87,27 +87,37 @@ pub fn spent_days(s: &State, now: Instant) -> f64 { s.project.work_time.map_or(0.0, |w| w.spent) + s.credits.clock.elapsed(now).as_secs_f64() / 86_400.0 } +/// The date and time now, as a project's start (days since 1899-12-30): fixed in a test that runs the motions' clock +/// (`anim::set_clock_at`, the equivalence harness), so a project made new starts the same on every run. +fn work_time_now() -> WorkTime { + #[cfg(test)] + if !crate::anim::still() { + return WorkTime { started: 46_000.5, spent: 0.0 }; + } + os_flp::project::work_time_now() +} + /// The project as it is saved (`App::save_to`): its time spent includes the session's clock (spec: /// flp/events/project-info.md "Writer notes": "then adds the time spent"). pub(crate) fn stamp(s: &State, p: &mut Project) { - let started = s.project.work_time.map_or_else(|| os_flp::project::work_time_now().started, |w| w.started); - p.work_time = Some(WorkTime { started, spent: spent_days(s, Instant::now()) }); + let started = s.project.work_time.map_or_else(|| work_time_now().started, |w| w.started); + p.work_time = Some(WorkTime { started, spent: spent_days(s, crate::anim::now()) }); } /// A project was opened (`App::open`): one whose file has no start is started now, as a writer creating a project does /// (spec: flp/events/project-info.md "Writer notes"); the clock starts again; the Song info box shows when the project /// asks for it (event 10). pub(crate) fn opened(s: &mut State) { - s.project.work_time.get_or_insert_with(os_flp::project::work_time_now); - s.credits.clock.restart(Instant::now()); + s.project.work_time.get_or_insert_with(work_time_now); + s.credits.clock.restart(crate::anim::now()); s.credits.song_box = s.project.show_info == Some(true); (s.credits.song_since, s.credits.song_alpha) = (None, 1.0); } /// A new project: started now, nothing spent (spec: ui/settings-choices.md "Info": "A new project starts now"). pub(crate) fn created(s: &mut State) { - s.project.work_time = Some(os_flp::project::work_time_now()); - s.credits.clock.restart(Instant::now()); + s.project.work_time = Some(work_time_now()); + s.credits.clock.restart(crate::anim::now()); s.credits.song_box = false; } @@ -332,7 +342,7 @@ impl App { /// Lemon Studio's main window came to the foreground (`on`) or left it: the working time counts only while it is in /// front (`WorkClock`). pub fn main_focused(&mut self, on: bool) { - self.state.credits.clock.focus(on, Instant::now()); + self.state.credits.clock.focus(on, crate::anim::now()); } /// Periodic (`App::tick`): the Info page shows the time spent by the minute; it repaints when that changes. @@ -352,7 +362,7 @@ impl App { pub(crate) fn reset_work_time(&mut self) { let s = &mut self.state; s.project.work_time.get_or_insert_with(os_flp::project::work_time_now).spent = 0.0; - s.credits.clock.restart(Instant::now()); + s.credits.clock.restart(crate::anim::now()); if self.saved.0.work_time == s.project.work_time { self.saved.0.work_time = None; } diff --git a/crates/os-app/src/rack.rs b/crates/os-app/src/rack.rs index 6b9d802b2..e053f22b1 100644 --- a/crates/os-app/src/rack.rs +++ b/crates/os-app/src/rack.rs @@ -881,7 +881,7 @@ pub fn clone_name(s: &State, r: usize) -> String { } fn seed() -> u64 { - std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos() as u64) + crate::clip_colors::clock() } pub fn random_color(s: &mut State, r: usize, salt: u64) { diff --git a/crates/os-app/src/remote.rs b/crates/os-app/src/remote.rs index 367b0bfbf..2bee1763f 100644 --- a/crates/os-app/src/remote.rs +++ b/crates/os-app/src/remote.rs @@ -610,7 +610,7 @@ impl App { live.last_in = Some(val); match l.smooth { Some(ms) => { - live.glide = Some((now, val, Instant::now(), Duration::from_millis(u64::from(ms)))); + live.glide = Some((now, val, crate::anim::now(), Duration::from_millis(u64::from(ms)))); } None => { live.set = Some(val); @@ -626,7 +626,7 @@ impl App { } for i in 0..self.remote.live.len() { let Some((from, to, since, over)) = self.remote.live[i].glide else { continue }; - let k = (since.elapsed().as_secs_f32() / over.as_secs_f32().max(1e-3)).min(1.0); + let k = (crate::anim::now().duration_since(since).as_secs_f32() / over.as_secs_f32().max(1e-3)).min(1.0); let v = from + (to - from) * k; if k >= 1.0 { self.remote.live[i].glide = None; diff --git a/crates/os-app/src/smooth.rs b/crates/os-app/src/smooth.rs index b89ab3dfc..674806610 100644 --- a/crates/os-app/src/smooth.rs +++ b/crates/os-app/src/smooth.rs @@ -352,7 +352,7 @@ impl App { } } if self.smooth.running() && self.smooth.at.is_none() { - self.smooth.at = Some(Instant::now()); + self.smooth.at = Some(crate::anim::now()); } r } diff --git a/crates/os-app/src/song_pos.rs b/crates/os-app/src/song_pos.rs index 33808dc13..3c331cb8d 100644 --- a/crates/os-app/src/song_pos.rs +++ b/crates/os-app/src/song_pos.rs @@ -135,7 +135,7 @@ impl App { self.drag = Some(Drag::SongPos { dx: x - hx }); } else { self.song_pos_step(); - self.drag = Some(Drag::SongPosHold { next: Instant::now() + FIRST_REPEAT }); + self.drag = Some(Drag::SongPosHold { next: crate::anim::now() + FIRST_REPEAT }); } } diff --git a/crates/os-app/src/tapper.rs b/crates/os-app/src/tapper.rs index acb22ad26..cd293cdc1 100644 --- a/crates/os-app/src/tapper.rs +++ b/crates/os-app/src/tapper.rs @@ -146,7 +146,7 @@ impl Default for TapperUi { count: Tapper::default(), flash_at: None, flash: 0.0, - epoch: Instant::now(), + epoch: crate::anim::now(), #[cfg(test)] test_time: None, #[cfg(test)] @@ -162,7 +162,7 @@ impl TapperUi { if let Some(t) = self.test_time { return t; } - self.epoch.elapsed().as_secs_f64() + crate::anim::now().saturating_duration_since(self.epoch).as_secs_f64() } /// Fades the pad's flash (spec §4, §8: over 250 ms). Returns whether it is still fading or just went out (the pad diff --git a/crates/os-app/src/views/browser.rs b/crates/os-app/src/views/browser.rs index 187a46a45..1bad645f9 100644 --- a/crates/os-app/src/views/browser.rs +++ b/crates/os-app/src/views/browser.rs @@ -912,7 +912,7 @@ pub fn paint(ui: &mut Ui, s: &State, r: Rect) { let over = ui.c.text_width(f, &row.label) - w; // Hovered, a label that does not fit scrolls (§9) rather than being cut. if hov && over > 0.0 { - let now = std::time::Instant::now(); + let now = crate::anim::now(); let since = s.browser_marquee.borrow().as_ref().filter(|m| m.key == row.key).map_or(now, |m| m.since); let off = marquee_offset(now.duration_since(since).as_secs_f32(), over); let lr = Rect::new(x + 25.0, y, w, ROW_H); From af015c3b4feac4d3e8039f66febce94b2554801a Mon Sep 17 00:00:00 2001 From: thacer13 Date: Tue, 10 Nov 2026 02:40:43 -0300 Subject: [PATCH 2/6] Equivalence harness: step 0 of the control-plane plan Drives the app without a window the way a user does, on FL's bundled projects, and records each step's fingerprint (the project, the project state the interface holds, what .lemon stores, the undo history, whether the engine holds the project, whether there is something to save); a trace recorded on one build replays on another and names the first input that behaves differently. At the end of each trace: undo to the start, redo to the end, the engine in step, a saved copy opened as the project the save wrote, a second save the same, ids. What these report on purpose is listed with its reason in equiv_accepted.txt. Tools: equiv_record, equiv_replay, equiv_twice, equiv_shrink, equiv_show, equiv_coverage (all ignored tests; the module's doc has the commands). Test code only. Co-Authored-By: Claude Opus 5.5 --- crates/os-app/src/equiv.rs | 1267 ++++++++++++++++++++++++++ crates/os-app/src/equiv_accepted.txt | 28 + crates/os-app/src/main.rs | 2 + crates/os-app/src/transport.rs | 12 + 4 files changed, 1309 insertions(+) create mode 100644 crates/os-app/src/equiv.rs create mode 100644 crates/os-app/src/equiv_accepted.txt diff --git a/crates/os-app/src/equiv.rs b/crates/os-app/src/equiv.rs new file mode 100644 index 000000000..5df549233 --- /dev/null +++ b/crates/os-app/src/equiv.rs @@ -0,0 +1,1267 @@ +// Copyright (C) 2026 ItsAlphaNeon +// SPDX-License-Identifier: GPL-3.0-or-later +// This file is part of Lemon Studio: https://github.com/ItsAlphaNeon/Lemon-Studio + +//! The equivalence harness (specs: roadmap/control-plane.md part 2, "Method"). It drives the app without a window the +//! way a user does (clicks, double clicks, right clicks, drags, the wheel and keys, on what the frame shows) and after +//! every step writes down a fingerprint of what the step may change: the project, the project state the interface +//! holds, what `.lemon` would store, the undo history, whether the engine holds the project as it is, and whether the +//! step left something to save. A trace (a project, its inputs and the fingerprints) recorded on one build is replayed on another: the +//! first step whose fingerprint differs names the input that behaves differently. +//! +//! At the end of each trace it also checks what needs no other build: undo back to the start gives the opened project, +//! redo gives the last one back, the engine holds what the project says, and a saved copy opens as the same project. +//! +//! Each recording and replay runs in a process of its own with a home folder of its own under `OS_EQUIV_DIR` (the +//! app saves projects, backups and settings there, and no trace sees another's). Run outside the sandbox and on the +//! hidden display (`hidden-tests`): +//! ```text +//! OS_EQUIV_DIR= OS_EQUIV_CORPUS= \ +//! cargo test -p os-app --bin lemonstudio equiv_record -- --ignored --nocapture +//! ``` +//! `equiv_show` runs in the test's own process: give it `HOME=/home` and no `DISPLAY` (`equiv_shrink` +//! runs each candidate in a process of its own, as a recording does). Both work in the folder the trace's recording +//! used, so `OS_EQUIV_DIR` must be the recording's folder: a save writes its path. +//! `equiv_replay` replays every trace in `OS_EQUIV_DIR`; `equiv_twice` records and replays on the same build (what +//! isn't deterministic yet). `equiv_coverage` adds up what the recordings in `OS_EQUIV_DIR` reached: each kind of +//! control, how many frames showed it, how often a step aimed at it, and whether it ever changed something. `OS_EQUIV_SEEDS` (2) traces per project, `OS_EQUIV_STEPS` (300) steps each, +//! `OS_EQUIV_ONLY=` limits the projects, `OS_EQUIV_SEED_FROM` (1) the first seed, `OS_EQUIV_FIRST=` takes the first n of the corpus. + +use std::fmt::{self, Write as _}; +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use winit::keyboard::{Key, NamedKey}; + +use crate::app::{Action, App}; + +fn var(name: &str, default: T) -> T { + std::env::var(name).ok().and_then(|v| v.parse().ok()).unwrap_or(default) +} + +/// FNV-1a, 64 bits: the same on every build and toolchain, unlike the standard library's hasher. +struct Fnv(u64); + +impl Fnv { + fn new() -> Fnv { + Fnv(0xcbf2_9ce4_8422_2325) + } + + fn bytes(&mut self, b: &[u8]) { + for &x in b { + self.0 = (self.0 ^ u64::from(x)).wrapping_mul(0x0100_0000_01b3); + } + } +} + +impl fmt::Write for Fnv { + fn write_str(&mut self, s: &str) -> fmt::Result { + self.bytes(s.as_bytes()); + Ok(()) + } +} + +fn hash(v: &impl fmt::Debug) -> u64 { + let mut h = Fnv::new(); + let _ = write!(h, "{v:?}"); + h.0 +} + +/// Project state the interface holds until save (specs: control-plane.md part 2, "Where main is"), as one value: a +/// field per line, so a difference names its field. +fn side(app: &App) -> String { + let (s, r) = (&app.state, &app.state.rack); + // "Unsorted" at the end of the group list reads as None (last) or as its place there: the same thing. + let unsorted = r.unsorted_at.filter(|&u| u < r.groups.len()); + let mut out = String::new(); + let mut f = |name: &str, v: &dyn fmt::Debug| { + let _ = writeln!(out, "{name}: {v:#?}"); + }; + f("tempo", &s.tempo.to_bits()); + f("pattern", &s.pattern); + f("song_mode", &s.song_mode); + f("filter", &r.filter); + f("groups", &r.groups); + f("zipped", &r.zipped); + f("locked", &r.locked); + f("loop_mode", &r.loop_mode); + f("loop_len", &r.loop_len); + f("loop_unit", &r.loop_unit); + f("loop_type", &r.loop_type); + f("unsorted_at", &unsorted); + out +} + +/// What a step may change, each part hashed. Parts are named so a difference says where it is. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Fp(Vec<(&'static str, u64)>); + +impl Fp { + fn of(app: &App) -> Fp { + let s = &app.state; + let side = hash(&side(app)); + let history = hash(&(s.history.entries(), s.history.at())); + Fp(vec![ + ("project", hash(&s.project)), + ("side", side), + ("lemon", hash(&crate::lemon_state::saved_form(s, &app.plugins))), + ("history", history), + // Whether the engine holds the project as it is: 0 nothing sent yet, 1 in step, 2 behind. Not the copy + // itself: what it was last handed while playing starts or pauses follows the audio thread's timing. + ("engine", match app.transport.sent_for_tests() { + None => 0, + Some(p) if p == crate::rack::play_project(s) => 1, + Some(_) => 2, + }), + ("unsaved", u64::from(app.unsaved())), + ]) + } + + fn parse(t: &str) -> Option { + let mut v = Vec::new(); + for part in t.split_whitespace() { + let (k, h) = part.split_once('=')?; + let k = ["project", "side", "lemon", "history", "engine", "unsaved"].into_iter().find(|n| *n == k)?; + v.push((k, u64::from_str_radix(h, 16).ok()?)); + } + Some(Fp(v)) + } + + /// The names of the parts that differ from `o`. + fn differs(&self, o: &Fp) -> Vec<&'static str> { + self.0.iter().zip(&o.0).filter(|(a, b)| a != b).map(|(a, _)| a.0).collect() + } +} + +impl fmt::Display for Fp { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for (i, (k, h)) in self.0.iter().enumerate() { + write!(f, "{}{k}={h:x}", if i > 0 { " " } else { "" })?; + } + Ok(()) + } +} + +/// One input, as a trace writes it. +#[derive(Debug, Clone, PartialEq)] +enum Input { + Move(f32, f32), + Down, + Up, + Right, + Wheel(f32, bool, bool), + Key(String, bool, bool), + Mods(bool, bool, bool), +} + +impl fmt::Display for Input { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let b = |v: bool| u8::from(v); + match self { + Input::Move(x, y) => write!(f, "move {x} {y}"), + Input::Down => write!(f, "down"), + Input::Up => write!(f, "up"), + Input::Right => write!(f, "right"), + Input::Wheel(dy, shift, ctrl) => write!(f, "wheel {dy} {} {}", b(*shift), b(*ctrl)), + Input::Key(k, ctrl, alt) => write!(f, "key {k} {} {}", b(*ctrl), b(*alt)), + Input::Mods(shift, ctrl, alt) => write!(f, "mods {} {} {}", b(*shift), b(*ctrl), b(*alt)), + } + } +} + +impl Input { + fn parse(t: &str) -> Option { + let w: Vec<&str> = t.split_whitespace().collect(); + let n = |i: usize| w.get(i).and_then(|v| v.parse::().ok()); + let b = |i: usize| w.get(i).map(|v| *v == "1"); + Some(match *w.first()? { + "move" => Input::Move(n(1)?, n(2)?), + "down" => Input::Down, + "up" => Input::Up, + "right" => Input::Right, + "wheel" => Input::Wheel(n(1)?, b(2)?, b(3)?), + "key" => Input::Key(w.get(1)?.to_string(), b(2)?, b(3)?), + "mods" => Input::Mods(b(1)?, b(2)?, b(3)?), + _ => return None, + }) + } + + fn apply(&self, app: &mut App) { + match self { + Input::Move(x, y) => { + app.mouse_move(*x, *y); + // `OS_EQUIV_SHOW=1`: what each move lands on, to read a shrunk trace. + if std::env::var("OS_EQUIV_SHOW").is_ok() { + let on = app.hits.iter().rev().find(|h| h.rect.contains(*x, *y)).map(|h| format!("{:?} ({})", h.action, h.hint)); + eprintln!(" at {x} {y}: {}", on.unwrap_or_else(|| "nothing".into())); + } + } + Input::Down => app.mouse_down(), + Input::Up => app.mouse_up(), + Input::Right => app.mouse_down_right(), + Input::Wheel(dy, shift, ctrl) => app.wheel(0.0, *dy, *shift, *ctrl), + Input::Key(k, ctrl, alt) => { + if let Some(key) = key_of(k) { + app.key(&key, *ctrl, *alt); + } + } + Input::Mods(shift, ctrl, alt) => { + app.shift = *shift; + app.ctrl = *ctrl; + app.alt = *alt; + } + } + } +} + +/// The keys the generator presses: letters, digits and the named keys the shortcuts use. +const NAMED: [(&str, NamedKey); 18] = [ + ("Delete", NamedKey::Delete), + ("Backspace", NamedKey::Backspace), + ("Escape", NamedKey::Escape), + ("Enter", NamedKey::Enter), + ("Tab", NamedKey::Tab), + ("Space", NamedKey::Space), + ("Up", NamedKey::ArrowUp), + ("Down", NamedKey::ArrowDown), + ("Left", NamedKey::ArrowLeft), + ("Right", NamedKey::ArrowRight), + ("Home", NamedKey::Home), + ("End", NamedKey::End), + ("PageUp", NamedKey::PageUp), + ("PageDown", NamedKey::PageDown), + ("Insert", NamedKey::Insert), + ("F6", NamedKey::F6), + ("F7", NamedKey::F7), + ("F9", NamedKey::F9), +]; +const CHARS: &str = "abcdefghijklmnopqrstuvwxyz0123456789+-"; + +fn key_of(name: &str) -> Option { + if let Some((_, k)) = NAMED.iter().find(|(n, _)| *n == name) { + return Some(Key::Named(*k)); + } + name.strip_prefix("c:").map(|c| Key::Character(c.into())) +} + +/// xorshift64*: a seed makes the same trace on every build. +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 >> 12; + self.0 ^= self.0 << 25; + self.0 ^= self.0 >> 27; + self.0.wrapping_mul(0x2545_f491_4f6c_dd1d) + } + + fn below(&mut self, n: usize) -> usize { + (self.next() % n.max(1) as u64) as usize + } + + fn chance(&mut self, percent: u64) -> bool { + self.next() % 100 < percent + } + + fn signed(&mut self, max: f32) -> f32 { + ((self.next() % 2001) as f32 / 1000.0 - 1.0) * max + } +} + +/// The frame the app paints into: one size for every build. +const FRAME: (u32, u32) = (1920, 1080); + +/// The app, the frame it paints and the clock its motions read. +struct Rig { + app: App, + canvas: os_ui::Canvas, + /// Steps run so far, for `OS_EQUIV_DUMP`. + step: usize, + /// How often the generator aimed at each kind of control: it favours the kinds it has used least. + used: std::collections::BTreeMap, + /// The kinds of control whose step changed the project, the interface's part of it or Lemon Studio's own state: + /// these weigh more, so the controls that edit are tried in more ways than those that only show something. + edits: std::collections::BTreeSet, + /// The kind the last generated step aimed at. + aimed: Option, + /// The first step after which two things shared an id, or one had none (`ids_fault`). + id_fault: Option, + /// How many frames showed each kind of control: with `used` and `edits`, what a recording reports as its + /// coverage (`.kinds`). + seen: std::collections::BTreeMap, +} + +/// A control's kind for the generator's novelty: its action's variant, and the variant inside it. +fn kind(a: &Action) -> String { + let d = format!("{a:?}"); + let mut parts = d.split(['(', ')', ' ', '{', ',']).filter(|p| p.chars().next().is_some_and(char::is_uppercase)); + match (parts.next(), parts.next()) { + (Some(a), Some(b)) => format!("{a}.{b}"), + (Some(a), None) => a.to_string(), + _ => d, + } +} + +/// Where the clock starts: the same instant for every rig of this run, so motions and holds see the same times. +fn epoch() -> Instant { + static EPOCH: std::sync::OnceLock = std::sync::OnceLock::new(); + *EPOCH.get_or_init(Instant::now) +} + +impl Rig { + /// Opens a copy of `project` in `work` (the app may save over what it opened). + fn open(project: &Path, work: &Path) -> Rig { + // The app writes settings, backups and recent files under the home folder: never the user's own. + let home = std::env::var("HOME").unwrap_or_default(); + assert!(Path::new(&home).starts_with(traces_dir()) || std::env::var("OS_EQUIV_HOME_OK").is_ok(), "HOME must be a folder under OS_EQUIV_DIR, not {home}"); + crate::transport::headless(); + crate::anim::set_clock_at(epoch()); + let _ = std::fs::create_dir_all(work); + let copy = work.join(project.file_name().expect("a file")); + std::fs::copy(project, ©).expect("copy the project"); + let mut app = App::new(None); + app.plugins = crate::plugins::Plugins::default(); + app.plugins.available = crate::plugins::builtin(); + app.plugins.effects = crate::plugins::builtin_effects(); + app.open(copy); + let mut rig = Rig { app, canvas: os_ui::Canvas::new(FRAME.0, FRAME.1, 1.0).expect("a canvas"), step: 0, used: Default::default(), edits: Default::default(), aimed: None, seen: Default::default(), id_fault: None }; + rig.settle(); + // Mixer effects are hosted a few ticks after loading. + for _ in 0..20 { + rig.tick(); + } + // The engine is handed the project as opened, as playing it would: from here an edit that doesn't reach the + // engine leaves it behind, where with nothing sent yet it would look the same as one that does. + rig.app.transport.update(&crate::rack::play_project(&rig.app.state)); + rig.paint(); + rig + } + + /// One frame of the app's loop: 10 ms on the motions' clock. + fn tick(&mut self) { + crate::anim::advance(10); + self.app.tick(); + } + + /// Until loading, hosting and recording are done: loading has no clock of ours (its budget is the machine's), so + /// it is waited for, not counted. + fn settle(&mut self) { + let t0 = Instant::now(); + while self.app.loading() && t0.elapsed() < Duration::from_secs(180) { + self.app.tick(); + std::thread::sleep(Duration::from_millis(2)); + } + } + + fn paint(&mut self) { + self.app.invalidate(); + self.app.paint(&mut self.canvas, None); + } + + /// After a step: a few frames, playback stopped (its position would follow the machine's clock), requests to the + /// window dropped (there is none), loading waited for, and a full frame painted so the hits are current. + fn after_step(&mut self, generation: u64) { + for _ in 0..4 { + self.tick(); + } + if self.app.transport.playing() { + self.app.act(Action::Stop); + self.tick(); + } + // A step that started or stopped playback: with no device the audio thread may stop it on its own before the + // Stop above, which leaves the engine's copy otherwise. Either way it is stopped and handed the project as it + // is now, so the fingerprint doesn't follow that race. + if self.app.transport.generation_for_tests() != generation { + self.app.transport.stop(); + self.app.transport.update(&crate::rack::play_project(&self.app.state)); + } + self.app.want_close = false; + self.settle(); + self.tick(); + self.paint(); + } + + fn run(&mut self, inputs: &[Input]) -> Fp { + let generation = self.app.transport.generation_for_tests(); + for i in inputs { + i.apply(&mut self.app); + } + self.after_step(generation); + // `OS_EQUIV_DUMP=`: the project and the interface's part of it after that step, numbered per run, to + // compare two runs that differ there. + if std::env::var("OS_EQUIV_DUMP").ok().and_then(|v| v.parse().ok()) == Some(self.step) { + let dir = traces_dir().join("dumps"); + let _ = std::fs::create_dir_all(&dir); + let n = std::fs::read_dir(&dir).map_or(0, |d| d.count()); + let _ = std::fs::write(dir.join(format!("{n}.txt")), format!("{:#?}\n{}", self.app.state.project, side(&self.app))); + } + if self.id_fault.is_none() { + self.id_fault = ids_fault(&self.app.state.project).map(|f| format!("step {}: {f}", self.step)); + } + self.step += 1; + let fp = Fp::of(&self.app); + // Each step's "unsaved" says whether that step made a change to save, not whether any step before it did. + self.app.mark_saved(); + fp + } + + /// Where the generator may aim: the centres of the hits the last frame registered, on the frame, with each one's + /// kind and whether it holds a value (a knob, fader, slider or anything automatable: the tempo, a value box). + fn targets(&self) -> Vec<(f32, f32, String, bool)> { + let (w, h) = (FRAME.0 as f32, FRAME.1 as f32); + self.app + .hits + .iter() + .filter(|h| h.rect.w >= 1.0 && h.rect.h >= 1.0) + // Only what a click there reaches: a control under a dialog, a menu or another window is covered (a + // click lands on what covers it), and aiming at it would leave the dialog's own buttons rarely tried. + .filter(|h| self.app.top_hit(h.rect.x + h.rect.w / 2.0, h.rect.y + h.rect.h / 2.0).is_some_and(|t| t.action == h.action)) + .map(|h| { + let valued = crate::knobs::Control::of(&h.action).is_some() || self.app.automation_target(&h.action).is_some(); + (h.rect.x + h.rect.w / 2.0, h.rect.y + h.rect.h / 2.0, kind(&h.action), valued) + }) + .filter(|&(x, y, ..)| (0.0..w).contains(&x) && (0.0..h).contains(&y)) + .collect() + } + + /// The next step's inputs, aimed at what the frame shows. + fn generate(&mut self, rng: &mut Rng) -> Vec { + let targets = self.targets(); + for k in targets.iter().map(|t| &t.2).collect::>() { + *self.seen.entry(k.clone()).or_default() += 1; + } + // Weighted by novelty: a kind of control aimed at n times weighs 1/(n+1), so the many step buttons or grid + // cells of one kind don't crowd out the few knobs, boxes and menus. A kind that has edited weighs four times + // as much: its drags, wheels and modifiers are where edits go wrong. The Settings window's controls weigh a + // quarter: they change preferences, not the project. + // A kind never aimed at yet weighs 64 times more, per control: a dialog's or a name box's buttons, shown for a + // few frames, are tried while they are there, not crowded out by the hundreds of step buttons and grid cells. + let weight = |k: &str, _valued: bool| { + let edit = if self.edits.contains(k) { 16 } else { 4 }; + let edit = if k.starts_with("Settings") { edit / 4 } else { edit }; + let used = u64::from(self.used.get(k).copied().unwrap_or(0)); + let fresh = if used == 0 { 64 } else { 1 }; + fresh * edit * 1_000_000 / (1 + used) + }; + let total: u64 = targets.iter().map(|t| weight(&t.2, t.3)).sum(); + if total == 0 { + self.aimed = None; + return vec![Input::Key("Escape".into(), false, false)]; + } + let mut at = rng.next() % total; + let Some((x, y, k, valued)) = targets.iter().find(|t| { + let w = weight(&t.2, t.3); + if at < w { + return true; + } + at -= w; + false + }).cloned() else { + self.aimed = None; + return vec![Input::Key("Escape".into(), false, false)]; + }; + *self.used.entry(k.clone()).or_default() += 1; + self.aimed = Some(k); + let (shift, ctrl, alt) = (rng.chance(12), rng.chance(12), rng.chance(5)); + let mods = Input::Mods(shift, ctrl, alt); + let none = Input::Mods(false, false, false); + // A control holding a value is mostly dragged or wheeled: a click alone rarely changes it. + let roll = if valued && rng.chance(70) { 58 + rng.below(27) } else { rng.below(100) }; + match roll { + 0..40 => vec![mods, Input::Move(x, y), Input::Down, Input::Up, none], + 40..48 => vec![Input::Move(x, y), Input::Down, Input::Up, Input::Down, Input::Up], + 48..58 => vec![Input::Move(x, y), Input::Right, Input::Up], + 58..73 => { + let mut v = vec![mods, Input::Move(x, y), Input::Down]; + let (mut px, mut py) = (x, y); + for _ in 0..1 + rng.below(5) { + px += rng.signed(80.0); + py += rng.signed(80.0); + v.push(Input::Move(px.round(), py.round())); + } + v.extend([Input::Up, none]); + v + } + 73..85 => vec![Input::Move(x, y), Input::Wheel(if rng.chance(50) { 1.0 } else { -1.0 }, rng.chance(20), rng.chance(30))], + _ => { + let k = if rng.chance(40) { + NAMED[rng.below(NAMED.len())].0.to_string() + } else { + let c = CHARS.as_bytes()[rng.below(CHARS.len())] as char; + format!("c:{c}") + }; + vec![Input::Key(k, rng.chance(25), rng.chance(8))] + } + } + } + + /// With no steps in the history (just opened, or a project opened or made by a step), undo starts from here. + fn undo_start(&self, start: &mut os_model::Project) { + if self.app.state.history.entries().len() <= 1 { + start.clone_from(&self.app.state.project); + } + } + + /// The checks that need no other build, at the end of a trace: each a name when it fails. + fn invariants(&mut self, opened: &os_model::Project, work: &Path) -> Vec { + let mut bad = Vec::new(); + // The checks run as a user would get to them: with no dialog open. A piano roll tool's preview goes as its × + // does (no undo step was made for it), a message box or form as Escape closes it. + crate::piano_dlg::cancel(&mut self.app.state); + for _ in 0..8 { + if !self.app.modal_open() { + break; + } + self.run(&[Input::Key("Escape".into(), false, false)]); + } + // And stopped: a take still recording (a Touch controller note with Start on input on) would keep changing the + // project, by the clock of a device that is not there, while the checks undo, redo and save. + if self.app.transport.playing() || self.app.state.rec.recording() { + self.app.act(crate::app::Action::Stop); + self.run(&[]); + } + // The engine holds what the project says (an edit that never reached it shows here): one finding per top-level + // field that differs, named by its first difference, so what is accepted is decided per field and the names + // don't depend on the accepted list. + if let Some(sent) = self.app.transport.sent_for_tests() { + let play = crate::rack::play_project(&self.app.state); + if sent != play { + let diff = differences(&sent, &play, false); + if diff.is_empty() { + bad.push("engine-behind".into()); + } + bad.extend(engine_findings(&diff)); + } + } + if let Some(f) = &self.id_fault { + bad.push(format!("ids({f})")); + } + let last = self.app.state.project.clone(); + let last_side = side(&self.app); + // Undo back to the start, then redo to where the trace ended. + let mut undone = 0; + while crate::history::undo(&mut self.app.state).is_some() && undone < 10_000 { + undone += 1; + self.tick(); + } + self.after_step(self.app.transport.generation_for_tests()); + if let Some(d) = difference(&canonical(opened), &canonical(&self.app.state.project), false) { + bad.push(format!("undo-to-start({undone}:{d})")); + } + // Redo as many steps as were done at the end: steps the trace itself undid stay undone. + for _ in 0..undone { + if crate::history::redo(&mut self.app.state).is_none() { + break; + } + self.tick(); + } + self.after_step(self.app.transport.generation_for_tests()); + if let Some(d) = difference(&canonical(&last), &canonical(&self.app.state.project), false) { + bad.push(format!("redo-to-end({d})")); + } + let now_side = side(&self.app); + if now_side != last_side { + let (a, b) = (leaves(&last_side), leaves(&now_side)); + let d = a.iter().zip(&b).find(|(x, y)| x != y).map_or("length".into(), |(x, y)| format!("{}:{}->{}", x.0, short(&x.1), short(&y.1))); + bad.push(format!("redo-to-end(side:{d})")); + } + // A saved copy opens as the same project, and the interface's part of it (tempo, current pattern, rack state) + // as the interface held it. + let copy = work.join("roundtrip.flp"); + // The project as the save wrote it: with what the interface holds written in (the tempo, the pattern shown, + // the rack's loops and groups, the playlist's view) and the plugins' states; the interface's part is compared + // on its own below. + let mut written = self.app.state.project.clone(); + match self.app.save_copy_with(copy.clone(), &mut |p| written = p.clone()) { + Some(Ok(())) => { + let mut again = open_app(©); + let back = again.state.project.clone(); + if let Some(d) = difference(&canonical(&written), &canonical(&back), true) { + bad.push(format!("roundtrip({d})")); + } + let (a, b) = (side(&self.app), side(&again)); + if a != b { + let (a, b) = (leaves(&a), leaves(&b)); + let d = a.iter().zip(&b).find(|(x, y)| x != y).map_or("length".into(), |(x, y)| format!("{}:{}->{}", x.0, short(&x.1), short(&y.1))); + bad.push(format!("roundtrip(side:{d})")); + } + // Saving what was opened again writes the same project: nothing drifts from save to save. + let second = work.join("roundtrip2.flp"); + match again.save_copy_with(second.clone(), &mut |_| {}) { + Some(Ok(())) => { + let model = |p: &Path| std::fs::read(p).ok().and_then(|b| os_flp::FlpFile::parse(&b).ok()).and_then(|f| os_flp::project::load(&f).ok()); + match (model(©), model(&second)) { + (Some(x), Some(y)) => { + if let Some(d) = difference(&x, &y, false) { + bad.push(format!("resave({d})")); + } + } + _ => bad.push("resave(unreadable)".into()), + } + } + other => bad.push(format!("resave-failed({other:?})")), + } + } + other => bad.push(format!("save-failed({other:?})")), + } + // One word each: a trace keeps them on one line. + bad.into_iter().map(|b| b.split_whitespace().collect::>().join("_")).collect() + } +} + +/// A project as the checks compare it: what its lists' order and empty patterns say is left out. The file holds the +/// clips in position order and the patterns by number, so their order in the lists means nothing; a pattern with +/// nothing in it (no notes, name, colour, length, loops or markers) has nothing to write and reads back as absent; +/// a send level at unity is the same as none. +fn canonical(p: &os_model::Project) -> os_model::Project { + let mut p = p.clone(); + for a in &mut p.arrangements { + // A clip's position, kind, item, length, track, mute bit and id are written from its fields on every save + // (os-flp encode_clip, encode_clips): the stored record's copy of them is left as it was read, so a clip made + // here holds zeros. Its offsets (bytes 24..32) are its window, written from it when it changed (an edit + // that cuts or trims a clip changes only the window); the window is compared. + for c in &mut a.clips { + c.raw[..14].fill(0); + c.raw[24..32].fill(0); + c.raw[19] &= !0x20; + c.raw[32..36].fill(0); + } + a.clips.sort_by_cached_key(|c| format!("{:012}:{:06}:{c:?}", c.position, c.track)); + } + // A pattern with nothing in it, loop lengths and loop mode alone included, is not stored, and one without notes + // keeps no loop lengths: FL keeps none for a pattern without notes (flp/events/patterns.md "Loop lengths"). + for q in &mut p.patterns { + if q.notes.is_empty() { + (q.loops, q.loop_off) = (Vec::new(), None); + } + } + p.patterns.retain(|q| { + !(q.notes.is_empty() && q.controls.is_empty() && q.name.is_none() && q.color.is_none() && q.length.is_none()) + || !q.markers.is_empty() + }); + p.patterns.sort_by_key(|q| q.number); + // A mixer track's origin is bookkeeping (os-model: tracks that differ only there are equal). + for m in &mut p.mixer { + m.origin = None; + } + // A route with no level record plays at unity (0.8 on the fader scale), as one with a record at 0.8 does. + for m in &mut p.mixer { + m.send_levels.retain(|&(_, l)| (l - 0.8).abs() > 1e-9); + // A save writes them by destination (os-flp mixer records), whatever order they were set in. + m.send_levels.sort_by_key(|&(d, _)| d); + } + p +} + +/// Whether every channel, pattern, mixer track, slot plugin, arrangement and clip has an id and no two share one (the +/// session gives them out from one counter, `control::ids`): what is wrong, if anything. +fn ids_fault(p: &os_model::Project) -> Option { + let mut all: Vec<(&str, os_model::Id)> = Vec::new(); + all.extend(p.channels.iter().map(|c| ("channel", c.id))); + all.extend(p.patterns.iter().map(|q| ("pattern", q.id))); + all.extend(p.mixer.iter().map(|m| ("track", m.id))); + all.extend(p.mixer.iter().flat_map(|m| m.slots.iter().flatten()).map(|x| ("slot", x.id))); + all.extend(p.arrangements.iter().map(|a| ("arrangement", a.id))); + all.extend(p.arrangements.iter().flat_map(|a| &a.clips).map(|c| ("clip", c.id))); + if let Some((kind, _)) = all.iter().find(|(_, id)| id.is_none()) { + return Some(format!("a {kind} without an id")); + } + let mut seen = std::collections::HashMap::new(); + for (kind, id) in &all { + if let Some(first) = seen.insert(id.0, *kind) { + return Some(format!("a {first} and a {kind} share id {}", id.0)); + } + } + None +} + +/// A second app with `path` opened, as the rig opens (builtin plugins only), loading waited for. +fn open_app(path: &Path) -> App { + let mut a = App::new(None); + a.plugins = crate::plugins::Plugins::default(); + a.plugins.available = crate::plugins::builtin(); + a.plugins.effects = crate::plugins::builtin_effects(); + a.open(path.to_path_buf()); + let t0 = Instant::now(); + while a.loading() && t0.elapsed() < Duration::from_secs(180) { + a.tick(); + std::thread::sleep(Duration::from_millis(2)); + } + a +} + +/// The leaves of a value's pretty debug form, each with its path (`channels[3].volume`). +fn leaves(text: &str) -> Vec<(String, String)> { + // A frame: its path, and the next item's index when it is a list. + let mut stack: Vec<(String, Option)> = vec![(String::new(), None)]; + let mut out = Vec::new(); + for line in text.lines() { + let l = line.trim().trim_end_matches(','); + if l.starts_with(['}', ']', ')']) { + stack.pop(); + if let Some((_, Some(i))) = stack.last_mut() { + *i += 1; + } + continue; + } + let (parent, index) = stack.last().cloned().unwrap_or_default(); + let (key, rest) = match index { + Some(i) => (format!("{parent}[{i}]"), l), + None => match l.split_once(": ") { + Some((k, r)) if !k.contains(' ') || k.starts_with('"') => (if parent.is_empty() { k.to_string() } else { format!("{parent}.{k}") }, r), + _ => (parent.clone(), l), + }, + }; + if rest.ends_with(['{', '[', '(']) { + // Lists and tuples number their items. + stack.push((key, (!rest.ends_with('{')).then_some(0))); + } else { + out.push((key, rest.to_string())); + if let Some((_, Some(i))) = stack.last_mut() { + *i += 1; + } + } + } + out +} + +/// Where two values differ: the first differing path with both sides, and how many paths differ, or None. Ids are +/// left out (they are not saved yet and are handed out again after an undo: specs control-plane.md part 2, step 12); +/// `rounding` takes numbers within 1e-4 as equal (what the file format keeps of them). +fn difference(a: &impl fmt::Debug, b: &impl fmt::Debug, rounding: bool) -> Option { + let diff = differences(a, b, rounding); + diff.first().map(|d| format!("{d}(+{})", diff.len() - 1)) +} + +/// Every leaf where `a` and `b` differ, as `path:a->b`. +/// `engine-behind` findings from the differences between what the engine holds and what the project says: one per +/// top-level field (the path up to its first `.` or `[`), in the order found, named by its first difference and the +/// count of the others. +fn engine_findings(diff: &[String]) -> Vec { + let top = |d: &str| d[..d.find(['.', '[', ':']).unwrap_or(d.len())].to_string(); + let mut groups: Vec<(String, &String, usize)> = Vec::new(); + for d in diff { + match groups.iter_mut().find(|(t, _, _)| *t == top(d)) { + Some(g) => g.2 += 1, + None => groups.push((top(d), d, 0)), + } + } + groups.into_iter().map(|(_, d, n)| format!("engine-behind({d}(+{n}))")).collect() +} + +fn differences(a: &impl fmt::Debug, b: &impl fmt::Debug, rounding: bool) -> Vec { + let kept = |l: Vec<(String, String)>| -> Vec<(String, String)> { l.into_iter().filter(|(k, _)| !(k.ends_with(".id") || k.contains(".id["))).collect() }; + let (a, b) = (kept(leaves(&format!("{a:#?}"))), kept(leaves(&format!("{b:#?}")))); + let same = |x: &str, y: &str| { + x == y || (rounding && matches!((x.parse::(), y.parse::()), (Ok(p), Ok(q)) if (p - q).abs() <= 1e-4)) + }; + let bm: std::collections::BTreeMap<&str, &str> = b.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect(); + let am: std::collections::BTreeMap<&str, &str> = a.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect(); + let mut diff: Vec = a + .iter() + .filter(|(k, v)| !bm.get(k.as_str()).is_some_and(|w| same(v, w))) + .map(|(k, v)| format!("{k}:{}->{}", short(v), bm.get(k.as_str()).map_or("absent".into(), |w| short(w)))) + .collect(); + diff.extend(b.iter().filter(|(k, _)| !am.contains_key(k.as_str())).map(|(k, v)| format!("{k}:absent->{}", short(v)))); + diff +} + +fn short(v: &str) -> String { + v.chars().filter(|c| !c.is_whitespace()).take(40).collect() +} + +/// A trace: the project it opens, the seed that made it, and each step's inputs with the fingerprint after it. +struct Trace { + project: String, + seed: u64, + opened: Fp, + steps: Vec<(Vec, Fp)>, + invariants: Vec, +} + +impl Trace { + fn write(&self) -> String { + let mut t = format!("project {}\nseed {}\nopened {}\n", self.project, self.seed, self.opened); + for (inputs, fp) in &self.steps { + let line: Vec = inputs.iter().map(ToString::to_string).collect(); + let _ = writeln!(t, "{}\t{fp}", line.join("; ")); + } + let _ = writeln!(t, "invariants {}", if self.invariants.is_empty() { "ok".into() } else { self.invariants.join(" ") }); + t + } + + fn read(t: &str) -> Option { + let mut lines = t.lines(); + let project = lines.next()?.strip_prefix("project ")?.to_string(); + let seed = lines.next()?.strip_prefix("seed ")?.parse().ok()?; + let opened = Fp::parse(lines.next()?.strip_prefix("opened ")?)?; + let mut steps = Vec::new(); + let mut invariants = Vec::new(); + for l in lines { + if let Some(rest) = l.strip_prefix("invariants ") { + invariants = rest.split_whitespace().filter(|w| *w != "ok").map(String::from).collect(); + continue; + } + let (inputs, fp) = l.split_once('\t')?; + let inputs = inputs.split("; ").map(Input::parse).collect::>>()?; + steps.push((inputs, Fp::parse(fp)?)); + } + Some(Trace { project, seed, opened, steps, invariants }) + } +} + +/// The projects of the corpus, sorted, as paths relative to it. +fn corpus() -> (PathBuf, Vec) { + let root = PathBuf::from(std::env::var("OS_EQUIV_CORPUS").expect("OS_EQUIV_CORPUS: a folder of projects")); + let mut v = Vec::new(); + let mut dirs = vec![root.clone()]; + while let Some(d) = dirs.pop() { + for e in std::fs::read_dir(&d).into_iter().flatten().flatten() { + let p = e.path(); + if p.is_dir() { + dirs.push(p); + } else if p.extension().is_some_and(|x| x.eq_ignore_ascii_case("flp")) { + v.push(p.strip_prefix(&root).expect("inside").to_string_lossy().into_owned()); + } + } + } + v.sort(); + if let Ok(only) = std::env::var("OS_EQUIV_ONLY") { + v.retain(|p| p.contains(&only)); + } + v.truncate(var("OS_EQUIV_FIRST", usize::MAX)); + (root, v) +} + +fn traces_dir() -> PathBuf { + PathBuf::from(std::env::var("OS_EQUIV_DIR").expect("OS_EQUIV_DIR: where traces live")) +} + +/// A scratch folder for one trace's copies (under the traces' folder, emptied first). +fn work_dir(name: &str) -> PathBuf { + let d = traces_dir().join("work").join(name); + let _ = std::fs::remove_dir_all(&d); + d +} + +fn trace_name(project: &str, seed: u64) -> String { + let stem: String = project.chars().map(|c| if c.is_alphanumeric() { c } else { '_' }).collect(); + format!("{stem}-{seed}") +} + +fn record(root: &Path, project: &str, seed: u64, steps: usize) -> Trace { + let work = work_dir(&trace_name(project, seed)); + let mut rig = Rig::open(&root.join(project), &work); + let opened = Fp::of(&rig.app); + let mut opened_project = rig.app.state.project.clone(); + let mut rng = Rng(seed.wrapping_mul(0x9e37_79b9_7f4a_7c15) | 1); + let mut out = Vec::new(); + let mut before = opened.clone(); + for _ in 0..steps { + let inputs = rig.generate(&mut rng); + let fp = rig.run(&inputs); + rig.undo_start(&mut opened_project); + if fp.differs(&before).iter().any(|p| ["project", "side", "lemon"].contains(p)) { + if let Some(k) = rig.aimed.clone() { + rig.edits.insert(k); + } + } + before = fp.clone(); + out.push((inputs, fp)); + } + let invariants = rig.invariants(&opened_project, &work); + let kinds: String = rig.seen.iter().map(|(k, n)| format!("{k}\t{n}\t{}\t{}\n", rig.used.get(k).copied().unwrap_or(0), u8::from(rig.edits.contains(k)))).collect(); + let _ = std::fs::write(traces_dir().join(format!("{}.kinds", trace_name(project, seed))), kinds); + Trace { project: project.into(), seed, opened, steps: out, invariants } +} + +/// Replays `t`: None when every fingerprint matches, else a report of the first difference. +fn replay(root: &Path, t: &Trace) -> Option { + // The folder the recording used: paths the project stores (a render, a save as) must be the same. + let work = work_dir(&trace_name(&t.project, t.seed)); + let mut rig = Rig::open(&root.join(&t.project), &work); + let mut opened_project = rig.app.state.project.clone(); + let fp = Fp::of(&rig.app); + if fp != t.opened { + return Some(format!("opening differs in {:?}", fp.differs(&t.opened))); + } + for (i, (inputs, want)) in t.steps.iter().enumerate() { + let got = rig.run(inputs); + rig.undo_start(&mut opened_project); + if &got != want { + let line: Vec = inputs.iter().map(ToString::to_string).collect(); + return Some(format!("step {i} ({}) differs in {:?}", line.join("; "), got.differs(want))); + } + } + let inv = rig.invariants(&opened_project, &work); + (inv != t.invariants).then(|| format!("invariants: recorded {:?}, now {:?}", t.invariants, inv)) +} + +/// What the invariants report on purpose (`equiv_accepted.txt`): (normalized prefix, why). +fn accepted() -> Vec<(&'static str, &'static str)> { + include_str!("equiv_accepted.txt").lines().filter(|l| !l.starts_with('#') && !l.is_empty()).filter_map(|l| l.split_once('\t')).collect() +} + +/// A finding as the accepted list names it: list indices as `[]`, the undo count after the kind left out. +fn normalized(finding: &str) -> String { + let mut out = String::new(); + let mut chars = finding.chars().peekable(); + while let Some(c) = chars.next() { + out.push(c); + if c == '[' && chars.peek().is_some_and(char::is_ascii_digit) { + while chars.peek().is_some_and(char::is_ascii_digit) { + chars.next(); + } + } + if c == '(' && out.starts_with("undo-to-start(") && out.len() == "undo-to-start(".len() { + let rest: String = chars.clone().collect(); + if let Some((n, _)) = rest.split_once(':').filter(|(n, _)| n.chars().all(|c| c.is_ascii_digit())) { + for _ in 0..=n.len() { + chars.next(); + } + } + } + } + out +} + +/// The findings of `inv` not on the accepted list. +fn unaccepted(inv: &[String]) -> Vec<&String> { + let list = accepted(); + inv.iter().filter(|i| !list.iter().any(|(p, _)| normalized(i).starts_with(p))).collect() +} + +#[test] +fn engine_findings_are_one_per_field_and_matched_one_by_one() { + let diff: Vec = + ["work_time[0].spent:0.5->0.0", "info.url:None->absent", "info.genre:x->y", "tempo:120->121"].map(String::from).into(); + let found = engine_findings(&diff); + assert_eq!(found, ["engine-behind(work_time[0].spent:0.5->0.0(+0))", "engine-behind(info.url:None->absent(+1))", "engine-behind(tempo:120->121(+0))"]); + assert_eq!(unaccepted(&found), [&found[2]], "the info and the work time are accepted, the tempo is not"); +} + +#[test] +fn findings_are_matched_against_the_accepted_list() { + assert_eq!(normalized("undo-to-start(25:mixer[6].enabled:true->false(+1))"), "undo-to-start(mixer[].enabled:true->false(+1))"); + assert_eq!(normalized("roundtrip(mixer[0].slots[4][0].name:x)"), "roundtrip(mixer[].slots[][].name:x)"); + let inv = ["undo-to-start(3:mixer[2].enabled:true->false(+0))".to_string(), "roundtrip(mixer[1].eq[0][1]:1->2(+0))".to_string()]; + assert_eq!(unaccepted(&inv), [&inv[1]]); +} + +#[test] +#[ignore = "the equivalence harness: records traces (see the module's doc)"] +fn equiv_record() { + let (root, projects) = corpus(); + let dir = traces_dir(); + std::fs::create_dir_all(&dir).expect("traces folder"); + let (seeds, steps) = (var("OS_EQUIV_SEEDS", 2u64), var("OS_EQUIV_STEPS", 300usize)); + let _ = root; + for p in &projects { + for seed in var("OS_EQUIV_SEED_FROM", 1u64)..=seeds { + let t0 = Instant::now(); + let r = isolated(&trace_name(p, seed), &format!("record\t{p}\t{seed}\t{steps}")); + eprintln!("{p} seed {seed}: {r} in {:.1} s", t0.elapsed().as_secs_f64()); + } + } +} + +#[test] +#[ignore = "the equivalence harness: replays recorded traces (see the module's doc)"] +fn equiv_replay() { + let (root, _) = corpus(); + let mut names: Vec = std::fs::read_dir(traces_dir()).expect("traces").flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|x| x == "trace")).collect(); + names.sort(); + let mut failed = 0; + let _ = root; + for n in &names { + let name = n.file_stem().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default(); + match isolated(&name, &format!("replay\t{}", n.display())).as_str() { + "same" => eprintln!("same: {}", n.display()), + r => { + failed += 1; + eprintln!("DIFFERENT: {}: {r}", n.display()); + } + } + } + assert_eq!(failed, 0, "{failed} of {} traces differ", names.len()); +} + +#[test] +#[ignore = "the equivalence harness: what is not deterministic on one build (see the module's doc)"] +fn equiv_twice() { + let (root, projects) = corpus(); + eprintln!("corpus {}: {} projects", root.display(), projects.len()); + let (seeds, steps) = (var("OS_EQUIV_SEEDS", 2u64), var("OS_EQUIV_STEPS", 300usize)); + let mut failed = 0; + for p in &projects { + for seed in var("OS_EQUIV_SEED_FROM", 1u64)..=seeds { + let name = trace_name(p, seed); + let recorded = isolated(&name, &format!("record\t{p}\t{seed}\t{steps}")); + let path = traces_dir().join(format!("{name}.trace")); + let t = Trace::read(&std::fs::read_to_string(&path).expect("recorded")).expect("a trace"); + assert_eq!(Trace::read(&t.write()).map(|a| a.write()), Some(t.write()), "the trace format round-trips"); + match isolated(&name, &format!("replay\t{}", path.display())).as_str() { + "same" => eprintln!("deterministic: {p} seed {seed}, {recorded}"), + r => { + failed += 1; + eprintln!("NOT DETERMINISTIC: {p} seed {seed}: {r}"); + } + } + } + } + assert_eq!(failed, 0); +} + +/// Runs one recording, replay or shrink candidate (`job`: "record\t\t\t", "replay\t" or +/// "check\t\t\t") in a process of +/// its own with a home folder of its own, so no trace sees what another left behind (settings, clipboards, the +/// process-wide switches): what it reports. +fn isolated(name: &str, job: &str) -> String { + let home = traces_dir().join("homes").join(name); + let _ = std::fs::remove_dir_all(&home); + let _ = std::fs::create_dir_all(&home); + let bin = no_programs(&home); + let out = std::process::Command::new(std::env::current_exe().expect("the test binary")) + .args(["equiv::equiv_one", "--exact", "--ignored", "--nocapture", "--test-threads=1"]) + .env("OS_EQUIV_ONE", job) + .env("HOME", &home) + // No audio device at all: a null device keeps no time, so its stream would spin a core for the whole trace. + .env("ALSA_CONFIG_PATH", home.join("no-alsa.conf")) + // Generated clicks reach Open folder, help links, the bell and plugin rebuilds: nothing the app starts may + // reach the user's desktop (a file manager opened through D-Bus on 2026-11-08) or run a build. + .env("PATH", &bin) + .env("XDG_RUNTIME_DIR", home.join("run")) + .env_remove("DBUS_SESSION_BUS_ADDRESS") + // No display either: whether a plugin window opens (and what the plugin's state then holds) must not depend on + // whether a hidden X server happened to be running. + .env_remove("DISPLAY") + .env_remove("WAYLAND_DISPLAY") + .env_remove("CARGO") + .output(); + if let Ok(started) = std::fs::read_to_string(home.join("started")) { + for l in started.lines() { + eprintln!("STARTED {name}: {l}"); + } + } + let _ = std::fs::remove_dir_all(&home); + let Ok(out) = out else { return "did not start".into() }; + let err = String::from_utf8_lossy(&out.stderr); + err.lines().find_map(|l| l.strip_prefix("EQUIV-RESULT ")).map_or_else( + || format!("no result ({})", err.lines().filter(|l| l.contains("panicked") || l.contains("error")).take(2).collect::>().join(" / ")), + String::from, + ) +} + +/// The only programs a trace's process can start: stand-ins that note the call in `/started` and do nothing. +/// Anything else the app tries to start is not found. What it returns is the trace's whole `PATH`. +fn no_programs(home: &std::path::Path) -> std::path::PathBuf { + let bin = home.join("bin"); + let _ = std::fs::create_dir_all(&bin); + let _ = std::fs::create_dir_all(home.join("run")); + let log = home.join("started"); + for p in ["xdg-open", "open", "explorer", "pw-play", "cargo", "gio", "notify-send"] { + let f = bin.join(p); + let _ = std::fs::write(&f, format!("#!/bin/sh\necho \"{p} $*\" >> '{}'\n", log.display())); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o755)); + } + } + bin +} + +/// The child of `isolated`: one recording (written to the traces folder), one replay or one shrink candidate. +#[test] +#[ignore = "the equivalence harness: one job of equiv_record or equiv_replay, in a process of its own"] +fn equiv_one() { + let Ok(job) = std::env::var("OS_EQUIV_ONE") else { return }; + let (root, _) = corpus(); + let parts: Vec<&str> = job.split('\t').collect(); + let result = match parts.as_slice() { + ["record", project, seed, steps] => { + let (seed, steps) = (seed.parse().expect("seed"), steps.parse().expect("steps")); + let t = record(&root, project, seed, steps); + std::fs::write(traces_dir().join(format!("{}.trace", trace_name(project, seed))), t.write()).expect("write trace"); + let new = unaccepted(&t.invariants); + format!("{} steps, findings {new:?} ({} accepted)", t.steps.len(), t.invariants.len() - new.len()) + } + ["check", project, seed, kind, path] => { + let steps: Option>> = + std::fs::read_to_string(path).expect("read").lines().map(|l| l.split("; ").map(Input::parse).collect()).collect(); + fails_here(&root, project, seed.parse().expect("a seed"), &steps.expect("a candidate"), kind).map_or_else(|| "holds".into(), |f| format!("fails: {f}")) + } + ["replay", path] => { + let t = Trace::read(&std::fs::read_to_string(path).expect("read")).expect("a trace"); + replay(&root, &t).unwrap_or_else(|| "same".into()) + } + _ => format!("unknown job {job:?}"), + }; + eprintln!("EQUIV-RESULT {}", result.replace('\n', " ")); +} + +/// Whether running `steps` on `project` breaks an invariant whose normalized name (`normalized`) starts with `kind` +/// and is not on the accepted list, in a process of its own as +/// a recording runs (settings and process-wide switches one candidate changes must not reach the next). +fn fails(_root: &Path, project: &str, seed: u64, steps: &[Vec], kind: &str) -> Option { + let file = traces_dir().join("shrink-candidate.txt"); + let text: String = steps.iter().map(|s| s.iter().map(ToString::to_string).collect::>().join("; ") + "\n").collect(); + std::fs::write(&file, text).expect("write the candidate"); + let r = isolated("shrink", &format!("check\t{project}\t{seed}\t{kind}\t{}", file.display())); + r.strip_prefix("fails: ").map(String::from) +} + +/// `fails` in this process, in the folder the trace's recording used (a save writes its path, so another folder can +/// change what is saved). +fn fails_here(root: &Path, project: &str, seed: u64, steps: &[Vec], kind: &str) -> Option { + let work = work_dir(&trace_name(project, seed)); + let mut rig = Rig::open(&root.join(project), &work); + let mut start = rig.app.state.project.clone(); + for s in steps { + rig.run(s); + rig.undo_start(&mut start); + } + let list = accepted(); + rig.invariants(&start, &work).into_iter().find(|i| { + let n = normalized(i); + n.starts_with(kind) && !list.iter().any(|(a, _)| n.starts_with(a)) + }) +} + +/// Cuts `OS_EQUIV_TRACE` down to the steps that still give a finding starting with `OS_EQUIV_KIND`, accepted ones +/// left aside (delta debugging: drop +/// chunks of steps, halving their size, while it still fails), and prints what is left as a trace. +#[test] +#[ignore = "the equivalence harness: shrinks a trace to what breaks an invariant (see the module's doc)"] +fn equiv_shrink() { + let (root, _) = corpus(); + let path = std::env::var("OS_EQUIV_TRACE").expect("OS_EQUIV_TRACE: a trace file"); + let kind = std::env::var("OS_EQUIV_KIND") + .expect("OS_EQUIV_KIND: the start of a finding as the accepted list writes it, e.g. undo-to-start or roundtrip(arrangements[].clips"); + let t = Trace::read(&std::fs::read_to_string(&path).expect("read")).expect("a trace"); + let mut steps: Vec> = t.steps.into_iter().map(|(i, _)| i).collect(); + // The shortest failing prefix first: the steps after it play no part. + let (mut lo, mut hi) = (0, steps.len()); + let Some(mut why) = fails(&root, &t.project, t.seed, &steps, &kind) else { panic!("the whole trace does not break {kind}") }; + while lo + 1 < hi { + let mid = (lo + hi) / 2; + match fails(&root, &t.project, t.seed, &steps[..mid], &kind) { + Some(w) => { + hi = mid; + why = w; + } + None => lo = mid, + } + } + steps.truncate(hi); + let mut chunk = steps.len().div_ceil(2); + while chunk >= 1 { + let mut i = 0; + while i < steps.len() && steps.len() > 1 { + let mut less = steps.clone(); + less.drain(i..(i + chunk).min(steps.len())); + match fails(&root, &t.project, t.seed, &less, &kind) { + Some(w) => { + steps = less; + why = w; + } + None => i += chunk, + } + } + if chunk == 1 { + break; + } + chunk = chunk.div_ceil(2); + } + eprintln!("{} steps of {} break it: {why}", steps.len(), t.project); + for s in &steps { + let line: Vec = s.iter().map(ToString::to_string).collect(); + eprintln!(" {}", line.join("; ")); + } +} + +/// Runs `OS_EQUIV_TRACE`'s steps and says after each what it landed on (`OS_EQUIV_SHOW`), the newest undo step, the +/// status message and the project's parts that changed, to read a shrunk trace. +#[test] +#[ignore = "the equivalence harness: shows what a trace's steps do (see the module's doc)"] +fn equiv_show() { + let (root, _) = corpus(); + let path = std::env::var("OS_EQUIV_TRACE").expect("OS_EQUIV_TRACE: a trace file"); + let t = Trace::read(&std::fs::read_to_string(&path).expect("read")).expect("a trace"); + let work = work_dir(&trace_name(&t.project, t.seed)); + let mut rig = Rig::open(&root.join(&t.project), &work); + let mut start = rig.app.state.project.clone(); + for (i, (inputs, _)) in t.steps.iter().enumerate() { + let line: Vec = inputs.iter().map(ToString::to_string).collect(); + eprintln!("step {i}: {}", line.join("; ")); + let before = rig.app.state.project.clone(); + rig.run(inputs); + rig.undo_start(&mut start); + let s = &rig.app.state; + eprintln!(" undo: {:?} ({} steps), message: {:?}", s.history.newest_done(), s.history.done(), s.message); + if let Some(d) = difference(&before, &s.project, false) { + eprintln!(" changed: {d}"); + } + } + // OS_EQUIV_SHOW=checks: then the end checks, as a recording runs them. + if std::env::var("OS_EQUIV_SHOW").as_deref() == Ok("checks") { + for f in rig.invariants(&start, &work) { + eprintln!("check: {f}"); + } + } + // OS_EQUIV_SHOW=save: then a saved copy, opened again, against the project (as the roundtrip check compares them, + // every difference). + if std::env::var("OS_EQUIV_SHOW").as_deref() == Ok("save") { + let copy = work.join("roundtrip.flp"); + let mut written = rig.app.state.project.clone(); + match rig.app.save_copy_with(copy.clone(), &mut |p| written = p.clone()) { + Some(Ok(())) => { + let again = open_app(©); + for d in differences(&canonical(&written), &canonical(&again.state.project), true).iter().take(60) { + eprintln!("saved and opened: {d}"); + } + let (a, b) = (leaves(&side(&rig.app)), leaves(&side(&again))); + for (x, y) in a.iter().zip(&b).filter(|(x, y)| x != y).take(20) { + eprintln!("saved and opened, interface: {}:{}->{}", x.0, short(&x.1), short(&y.1)); + } + } + other => eprintln!("save failed: {other:?}"), + } + } +} + +/// What the recordings in `OS_EQUIV_DIR` reached (their `.kinds` files): the kinds of control shown but never aimed +/// at, aimed at but never changing the project, the interface's part of it or Lemon Studio's state, and those that did. +#[test] +#[ignore = "the equivalence harness: what the recordings reached (see the module's doc)"] +fn equiv_coverage() { + let mut all: std::collections::BTreeMap = Default::default(); + let mut traces = 0; + for e in std::fs::read_dir(traces_dir()).expect("traces folder").flatten() { + if e.path().extension().is_none_or(|x| x != "kinds") { + continue; + } + traces += 1; + for l in std::fs::read_to_string(e.path()).unwrap_or_default().lines() { + let f: Vec<&str> = l.split('\t').collect(); + let [k, seen, used, edited] = f[..] else { continue }; + let a = all.entry(k.to_string()).or_default(); + a.0 += seen.parse::().unwrap_or(0); + a.1 += used.parse::().unwrap_or(0); + a.2 += u64::from(edited == "1"); + a.3 += 1; + } + } + eprintln!("{traces} recordings, {} kinds of control shown", all.len()); + for (title, pick) in [ + ("shown, never aimed at", &(|a: &(u64, u64, u64, u64)| a.1 == 0) as &dyn Fn(&(u64, u64, u64, u64)) -> bool), + ("aimed at, never changed anything", &|a| a.1 > 0 && a.2 == 0), + ("changed something", &|a| a.2 > 0), + ] { + let rows: Vec<_> = all.iter().filter(|(_, a)| pick(a)).collect(); + eprintln!("\n{title}: {}", rows.len()); + for (k, (seen, used, edited, traces)) in rows { + eprintln!(" {k}: shown in {seen} frames of {traces} recordings, aimed at {used} times, changed in {edited}"); + } + } +} diff --git a/crates/os-app/src/equiv_accepted.txt b/crates/os-app/src/equiv_accepted.txt new file mode 100644 index 000000000..2560518a8 --- /dev/null +++ b/crates/os-app/src/equiv_accepted.txt @@ -0,0 +1,28 @@ +# What the equivalence harness's invariants report and Lemon Studio does on purpose, with why. A finding whose +# normalized form (indices as [], an undo count dropped) starts with an entry's first field is reported as accepted. +undo-to-start(mixer[].enabled A mixer track's mute and solo make no undo step, as in FL 24.1.1 (specs: ui/undo-steps.md); FL's change log says 24.2.99 added one, not measured here. +undo-to-start(patterns[].length The pattern length box makes no undo step, as in FL (specs: ui/undo-steps.md). +undo-to-start(patterns: The first edit of a project without patterns makes pattern 1, which no step takes back. +roundtrip(mixer[].slots[][].name A plugin Lemon Studio hosts is saved as a wrapped plugin (specs: flp/events/plugins.md). +undo-to-start(mixer[].armed Arming a track makes no undo step and is not saved, as in FL (measured on FL 24.1.1, 2026-11-08). +roundtrip(mixer[].armed Arming a track is not saved, as in FL (measured on FL 24.1.1, 2026-11-08: a save differs only in its time). +undo-to-start(performance Performance mode on or off makes no undo step, as in FL (measured on FL 24.1.1, 2026-11-08). +resave(tempo A tempo with an initial value opens at that value, not the saved tempo, as in FL (measured on FL 24.1.1). +undo-to-start(tempo Save writes the interface's tempo into the project, and no undo step takes a save back; the tempo moves into the project in control-plane step 5. +undo-to-start(song_mode Save writes the interface's song mode into the project, and no undo step takes a save back (control-plane step 5). +undo-to-start(current_pattern Save writes the pattern shown into the project, and no undo step takes a save back (control-plane step 5). +undo-to-start(playlist_view Save writes the playlist's view (scroll, zoom) into the project, and no undo step takes a save back. +undo-to-start(work_time The time spent on the project counts on while it is open; no undo step takes it back. +roundtrip(channels[].rack.origin A channel's origin is the file block it was read from: a channel made in Lemon Studio has none until it is saved and opened, and blocks are numbered anew in each file. +roundtrip(channels[].rack.stored_color The colour event a channel was opened with is a display copy: saving writes Channel::color, so a channel made or recoloured here reads back with the new one. +roundtrip(saved_by: A save writes the version FL 26 opens without a warning (flp/events/project-info.md), whatever version the project was opened with. +resave(mixer[].volume A mixer volume with an initial value (event 216) opens at that value in song mode, and moving the fader leaves the initial value as it was (flp/engine-events.md, measured on FL 24.1.1 and 26). +roundtrip(side:loop_mode:true->false Open (specs REQUESTS 13): loop mode switched on in a pattern with no loop lengths is stored nowhere, and reads back off; whether FL keeps it is not measured. +redo-to-end(side:loop_mode:true->false Open (specs REQUESTS 13): loop mode with no loop lengths is not part of the pattern undo puts back, so it reads back off; whether FL keeps it is not measured. +roundtrip(side:tempo: A song-mode project with an initial tempo (event 216) opens at it, not at the saved tempo, as in FL (flp/engine-events.md "Event 216"; the resave entry above). +roundtrip(mixer[].armed Arming a track is not saved, as in FL (measured on FL 24.1.1, 2026-11-08; the undo entry above). +undo-to-start(show_info Show info on opening makes no undo step, as in FL (measured on FL 24.1.1, 2026-11-09: the box switched on, EDIT still offered no Undo). +roundtrip(mixer[].volume A mixer volume with an initial value (event 216) opens at that value in song mode, and moving the fader leaves the initial value as it was (flp/engine-events.md; the resave entry above). +engine-behind(show_info Show info on opening is not handed to playback, which plays nothing of it; control-plane step 4 reports every change from the one entry point. +engine-behind(info. The project info (title, genre, author, comments, link) is not handed to playback, which plays nothing of it; control-plane step 4 reports every change from the one entry point. +engine-behind(work_time The project info's work time (reset on its page) is not handed to playback, which plays nothing of it; control-plane step 4 reports every change from the one entry point. diff --git a/crates/os-app/src/main.rs b/crates/os-app/src/main.rs index f7fe46489..3ff2a4195 100644 --- a/crates/os-app/src/main.rs +++ b/crates/os-app/src/main.rs @@ -29,6 +29,8 @@ mod dropout_log; #[cfg(test)] mod checks; #[cfg(test)] +mod equiv; +#[cfg(test)] mod open_bench; mod browser; mod browser_index; diff --git a/crates/os-app/src/transport.rs b/crates/os-app/src/transport.rs index ed748ee8c..f5b35e22c 100644 --- a/crates/os-app/src/transport.rs +++ b/crates/os-app/src/transport.rs @@ -700,6 +700,18 @@ impl Transport { } } + /// Tests: how many times playback was started or stopped (the equivalence harness). + #[cfg(test)] + pub fn generation_for_tests(&self) -> u64 { + self.shared.generation.load(Ordering::Acquire) + } + + /// Tests: the project the engine was last given whole, with the live edits since (the equivalence harness). + #[cfg(test)] + pub fn sent_for_tests(&self) -> Option { + self.sent.lock().ok().and_then(|s| s.clone()) + } + pub fn update(&self, project: &Project) { // Levels alone reach the playing engines in place: a new engine at every move of a knob takes milliseconds of // building each time (up to some 70 in a large project). What was last sent says what changed. From 10698b79ef191914b2c3a79fb430b14e8f00f44f Mon Sep 17 00:00:00 2001 From: thacer13 Date: Tue, 10 Nov 2026 07:23:20 -0300 Subject: [PATCH 3/6] Harness: the engine findings' test matches against a list of its own, not the accepted list of the day Co-Authored-By: Claude Opus 5.5 --- crates/os-app/src/equiv.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/os-app/src/equiv.rs b/crates/os-app/src/equiv.rs index 5df549233..2c966f74e 100644 --- a/crates/os-app/src/equiv.rs +++ b/crates/os-app/src/equiv.rs @@ -937,7 +937,9 @@ fn engine_findings_are_one_per_field_and_matched_one_by_one() { ["work_time[0].spent:0.5->0.0", "info.url:None->absent", "info.genre:x->y", "tempo:120->121"].map(String::from).into(); let found = engine_findings(&diff); assert_eq!(found, ["engine-behind(work_time[0].spent:0.5->0.0(+0))", "engine-behind(info.url:None->absent(+1))", "engine-behind(tempo:120->121(+0))"]); - assert_eq!(unaccepted(&found), [&found[2]], "the info and the work time are accepted, the tempo is not"); + let list = ["engine-behind(info.", "engine-behind(work_time"]; + let open: Vec<&String> = found.iter().filter(|f| !list.iter().any(|a| normalized(f).starts_with(a))).collect(); + assert_eq!(open, [&found[2]], "with the info and the work time accepted, the tempo is still found"); } #[test] From 00a6b1f88c4725193c61218928563adab2914d34 Mon Sep 17 00:00:00 2001 From: thacer13 Date: Tue, 10 Nov 2026 09:59:06 -0300 Subject: [PATCH 4/6] Harness: the project's fingerprint leaves out what the side's holds, so moving that state into the project changes no fingerprint Co-Authored-By: Claude Opus 5.5 --- crates/os-app/src/equiv.rs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/crates/os-app/src/equiv.rs b/crates/os-app/src/equiv.rs index 2c966f74e..17ce3e71a 100644 --- a/crates/os-app/src/equiv.rs +++ b/crates/os-app/src/equiv.rs @@ -67,6 +67,22 @@ fn hash(v: &impl fmt::Debug) -> u64 { h.0 } +/// The project without what `side` holds (the tempo, the pattern shown, the song mode, the rack's groups, locks, zips +/// and loops): a build may keep those in the interface until a save or in the project itself (control-plane step 5), +/// and they are compared once, in `side`. What a save writes of them is checked by the save and reopen checks. +fn project_part(p: &os_model::Project) -> os_model::Project { + let mut p = p.clone(); + (p.tempo, p.current_pattern, p.song_mode) = (None, None, None); + p.groups.clear(); + for c in &mut p.channels { + (c.rack.locked, c.rack.zipped, c.rack.group) = (false, false, 0); + } + for q in &mut p.patterns { + (q.loops, q.loop_off) = (Vec::new(), None); + } + p +} + /// Project state the interface holds until save (specs: control-plane.md part 2, "Where main is"), as one value: a /// field per line, so a difference names its field. fn side(app: &App) -> String { @@ -102,7 +118,7 @@ impl Fp { let side = hash(&side(app)); let history = hash(&(s.history.entries(), s.history.at())); Fp(vec![ - ("project", hash(&s.project)), + ("project", hash(&project_part(&s.project))), ("side", side), ("lemon", hash(&crate::lemon_state::saved_form(s, &app.plugins))), ("history", history), From 2002ce8b597ee4609344bb0e21c310770cd71053 Mon Sep 17 00:00:00 2001 From: thacer13 Date: Tue, 10 Nov 2026 10:59:56 -0300 Subject: [PATCH 5/6] Harness: undo to the start and redo to the end compare the project's part, as the fingerprint does Co-Authored-By: Claude Opus 5.5 --- crates/os-app/src/equiv.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/os-app/src/equiv.rs b/crates/os-app/src/equiv.rs index 17ce3e71a..d0208b342 100644 --- a/crates/os-app/src/equiv.rs +++ b/crates/os-app/src/equiv.rs @@ -561,7 +561,8 @@ impl Rig { self.tick(); } self.after_step(self.app.transport.generation_for_tests()); - if let Some(d) = difference(&canonical(opened), &canonical(&self.app.state.project), false) { + // The project's part only: what `side` holds is compared there (the end only: what undo leaves, as FL does). + if let Some(d) = difference(&canonical(&project_part(opened)), &canonical(&project_part(&self.app.state.project)), false) { bad.push(format!("undo-to-start({undone}:{d})")); } // Redo as many steps as were done at the end: steps the trace itself undid stay undone. @@ -572,7 +573,7 @@ impl Rig { self.tick(); } self.after_step(self.app.transport.generation_for_tests()); - if let Some(d) = difference(&canonical(&last), &canonical(&self.app.state.project), false) { + if let Some(d) = difference(&canonical(&project_part(&last)), &canonical(&project_part(&self.app.state.project)), false) { bad.push(format!("redo-to-end({d})")); } let now_side = side(&self.app); From 742c4d9a457dc024770fdbffa2a24bf44071d25c Mon Sep 17 00:00:00 2001 From: thacer13 Date: Tue, 10 Nov 2026 11:58:02 -0300 Subject: [PATCH 6/6] Harness: a save's plugin states written into the project are accepted after undo, until step 15 Co-Authored-By: Claude Opus 5.5 --- crates/os-app/src/equiv_accepted.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/os-app/src/equiv_accepted.txt b/crates/os-app/src/equiv_accepted.txt index 2560518a8..18ea6bb2c 100644 --- a/crates/os-app/src/equiv_accepted.txt +++ b/crates/os-app/src/equiv_accepted.txt @@ -11,6 +11,7 @@ resave(tempo A tempo with an initial value opens at that value, not the saved te undo-to-start(tempo Save writes the interface's tempo into the project, and no undo step takes a save back; the tempo moves into the project in control-plane step 5. undo-to-start(song_mode Save writes the interface's song mode into the project, and no undo step takes a save back (control-plane step 5). undo-to-start(current_pattern Save writes the pattern shown into the project, and no undo step takes a save back (control-plane step 5). +undo-to-start(mixer[].slots[][].name Save writes the plugins' states into the project (a slot's plugin as the file stores it, Edison as its wrapper), and no undo step takes a save back (control-plane step 15: plugin states in the model). undo-to-start(playlist_view Save writes the playlist's view (scroll, zoom) into the project, and no undo step takes a save back. undo-to-start(work_time The time spent on the project counts on while it is open; no undo step takes it back. roundtrip(channels[].rack.origin A channel's origin is the file block it was read from: a channel made in Lemon Studio has none until it is saved and opened, and blocks are numbered anew in each file.