From 512c26d106db0270d8e9090f768532aaefca6025 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 02:04:04 +0000 Subject: [PATCH 1/2] test(cli): pin a complement subcommand via golden test (red) A golden test that runs `griff complement ` and snapshots its summary line. Fails until the subcommand exists: clap rejects the unknown subcommand and no snapshots are blessed yet. --- cli/tests/cli.rs | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/cli/tests/cli.rs b/cli/tests/cli.rs index bc6a0640..c9262737 100644 --- a/cli/tests/cli.rs +++ b/cli/tests/cli.rs @@ -1,10 +1,10 @@ //! S0 golden/characterization tests for the `griff` CLI. //! //! Every CLI subcommand (`import`, `inspect`, `export`, `classify`, -//! `structure`, `phrases`, `generate`) is run against every committed fixture -//! and its stdout/stderr pinned to a golden snapshot. These tests describe what -//! the CLI *does* today; they must not be "fixed" by changing expectations -//! without a deliberate re-bless. +//! `structure`, `phrases`, `generate`, `complement`) is run against every +//! committed fixture and its stdout/stderr pinned to a golden snapshot. These +//! tests describe what the CLI *does* today; they must not be "fixed" by +//! changing expectations without a deliberate re-bless. //! //! Regenerate fixtures: `cargo test -p griff-cli -- --ignored regenerate` //! Re-bless snapshots: `GRIFF_BLESS=1 cargo test -p griff-cli` @@ -147,6 +147,28 @@ fn generate_golden() { } } +#[test] +fn complement_golden() { + for (name, _) in fixtures() { + let src = fixture_path(name); + let dst = env::temp_dir().join(format!("griff_s0_complement_{name}.mid")); + fs::remove_file(&dst).ok(); + + let out = griff( + &["complement", src.to_str().unwrap(), dst.to_str().unwrap()], + dst.to_str(), + ); + let out = out.replace(src.to_str().unwrap(), ""); + assert_golden(&format!("complement__{name}"), &out); + + assert!( + dst.exists(), + "complement must have written the output file for `{name}`" + ); + fs::remove_file(&dst).ok(); + } +} + /// A missing input file is observable CLI behavior worth pinning. #[test] fn missing_file_golden() { From f041fe7e542ad66f4f60d2138e8b13cb2aa7b738 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 16 Jun 2026 02:04:05 +0000 Subject: [PATCH 2/2] =?UTF-8?q?feat(cli):=20complement=20subcommand=20?= =?UTF-8?q?=E2=80=94=20tab-seeded=20complementary=20part=20(S13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface the S13 ComplementArranger (complement::arrange_complement, ADR-0015) on the .gpx/.mid front door. `griff complement [--mode M] [--seed N] [--offset N]` derives a complementary part B from the source's primary track — one of six relations (rhythm_lock, register_contrast, call_response, support_layer, octave_double, counter_melody) — and writes A plus B to MIDI. Deterministic under a fixed seed. Greens the complement_golden test by blessing its summary snapshots. On a real GP6 tab: a rhythm-locked second part appended as a new track alongside the two guitars. --- cli/src/main.rs | 107 ++++++++++++++++++ cli/tests/cli.rs | 55 +++++++++ .../snapshots/complement__multi_track.txt | 5 + .../complement__opts_counter_melody.txt | 5 + .../snapshots/complement__seven_eight.txt | 5 + .../snapshots/complement__simple_4_4.txt | 5 + .../snapshots/complement__tempo_change.txt | 5 + .../snapshots/complement__two_phrases.txt | 5 + .../error__complement_invalid_mode.txt | 5 + 9 files changed, 197 insertions(+) create mode 100644 cli/tests/snapshots/complement__multi_track.txt create mode 100644 cli/tests/snapshots/complement__opts_counter_melody.txt create mode 100644 cli/tests/snapshots/complement__seven_eight.txt create mode 100644 cli/tests/snapshots/complement__simple_4_4.txt create mode 100644 cli/tests/snapshots/complement__tempo_change.txt create mode 100644 cli/tests/snapshots/complement__two_phrases.txt create mode 100644 cli/tests/snapshots/error__complement_invalid_mode.txt diff --git a/cli/src/main.rs b/cli/src/main.rs index 26ca7fe4..f3fcd85a 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -100,6 +100,29 @@ enum Command { bars: usize, }, + /// Arrange a complementary part (S13) for a tab's primary track — a second + /// guitar/bass derived from it — and write both parts to a MIDI file. + Complement { + /// Source MIDI or Guitar Pro file whose primary track is part A. + #[arg(value_name = "INPUT")] + input: PathBuf, + /// Output `.mid` file for A plus the generated part B. + #[arg(value_name = "OUTPUT")] + output: PathBuf, + /// Relation mode: `rhythm_lock`, `register_contrast`, `call_response`, + /// `support_layer`, `octave_double`, or `counter_melody`. + #[arg(long, default_value = "rhythm_lock")] + mode: String, + /// Deterministic seed — the same seed always yields the same part. + #[arg(long, default_value_t = 0)] + seed: u64, + /// Semitone shift of B's register relative to A (e.g. -12 = octave + /// down). Defaults per mode: `octave_double` and `register_contrast` + /// reject a zero shift, so they default to -12; others to 0. + #[arg(long, allow_hyphen_values = true)] + offset: Option, + }, + /// Interactively curate a MIDI or Guitar Pro file into a corpus `ChunkMeta` JSON record. Curate { /// Path to the MIDI or Guitar Pro file to curate. @@ -133,6 +156,13 @@ fn run() -> Result<(), CliError> { seed, bars, } => cmd_generate(&input, &output, seed, bars), + Command::Complement { + input, + output, + mode, + seed, + offset, + } => cmd_complement(&input, &output, &mode, seed, offset), Command::Curate { path, output, @@ -618,6 +648,73 @@ fn first_bar_rhythm(score: &Score) -> Vec { vec![quarter; 4] } +/// Arranges a complementary part B (S13) for the primary track of `input` and +/// writes A plus B to a MIDI file. +fn cmd_complement( + input: &Path, + output: &Path, + mode: &str, + seed: u64, + offset: Option, +) -> Result<(), CliError> { + let data = fs::read(input)?; + let score = import::import_score_auto(&data)?; + let relation = parse_relation_mode(mode)?; + // Part A is the first track that actually sounds: GP import keeps rest-only + // tracks, so track 0 may be empty while a later track carries the riff. + let track_index = score + .tracks + .iter() + .position(|t| primary_voice_note_count(t) > 0) + .ok_or(CliError::Complement( + complement::ComplementError::PartHasNoNotes, + ))?; + let spec = complement::ComplementSpec { + mode: relation, + register_offset: offset.unwrap_or_else(|| default_offset(relation)), + }; + let candidate = + complement::arrange_complement(&score, track_index, spec, generate::GenerationSeed(seed))?; + let out_bytes = midi::export_score(&candidate.score)?; + fs::write(output, &out_bytes)?; + println!( + "complement ({label}, seed {seed}) — part B appended as track {b} \ + ({n} bytes) -> {out}", + label = relation.label(), + b = candidate.part_b_index, + n = out_bytes.len(), + out = output.display(), + ); + Ok(()) +} + +/// The default register shift when `--offset` is omitted: an octave down for +/// the modes that reject a zero shift, otherwise none. +const fn default_offset(mode: complement::RelationMode) -> i8 { + match mode { + complement::RelationMode::OctaveDouble | complement::RelationMode::RegisterContrast => -12, + _ => 0, + } +} + +/// Parses a `--mode` string into a [`complement::RelationMode`]. +fn parse_relation_mode(mode: &str) -> Result { + Ok(match mode { + "rhythm_lock" => complement::RelationMode::RhythmLock, + "register_contrast" => complement::RelationMode::RegisterContrast, + "call_response" => complement::RelationMode::CallResponse, + "support_layer" => complement::RelationMode::SupportLayer, + "octave_double" => complement::RelationMode::OctaveDouble, + "counter_melody" => complement::RelationMode::CounterMelody, + other => { + return Err(CliError::Argument(format!( + "unknown complement mode '{other}' (try rhythm_lock, register_contrast, \ + call_response, support_layer, octave_double, counter_melody)" + ))); + } + }) +} + fn cmd_curate(path: &Path, output: Option<&Path>, ensemble: bool) -> Result<(), CliError> { let data = fs::read(path)?; let score = import::import_score_auto(&data)?; @@ -964,8 +1061,10 @@ enum CliError { Import(ImportError), Midi(MidiError), Json(serde_json::Error), + Argument(String), Ensemble(String), Generate(generate::GenerationError), + Complement(complement::ComplementError), } impl fmt::Display for CliError { @@ -975,8 +1074,10 @@ impl fmt::Display for CliError { Self::Import(e) => write!(f, "import error: {e}"), Self::Midi(e) => write!(f, "MIDI error: {e}"), Self::Json(e) => write!(f, "JSON error: {e}"), + Self::Argument(msg) => write!(f, "argument error: {msg}"), Self::Ensemble(msg) => write!(f, "ensemble error: {msg}"), Self::Generate(e) => write!(f, "generation error: {e:?}"), + Self::Complement(e) => write!(f, "complement error: {e:?}"), } } } @@ -1005,6 +1106,12 @@ impl From for CliError { } } +impl From for CliError { + fn from(e: complement::ComplementError) -> Self { + Self::Complement(e) + } +} + // ── tests ───────────────────────────────────────────────────────────────────── /// Red → green for the Codex P2 finding on PR #36: ensemble part selection diff --git a/cli/tests/cli.rs b/cli/tests/cli.rs index c9262737..b032df8c 100644 --- a/cli/tests/cli.rs +++ b/cli/tests/cli.rs @@ -169,6 +169,61 @@ fn complement_golden() { } } +/// Pins the non-default argument path: a different `--mode`, an explicit +/// `--seed`, and a negative `--offset` (which clap must accept as a value). +#[test] +fn complement_options_golden() { + let src = fixture_path("simple_4_4"); + let dst = env::temp_dir().join("griff_s0_complement_opts.mid"); + fs::remove_file(&dst).ok(); + + let out = griff( + &[ + "complement", + src.to_str().unwrap(), + dst.to_str().unwrap(), + "--mode", + "counter_melody", + "--seed", + "42", + "--offset", + "-12", + ], + dst.to_str(), + ); + let out = out.replace(src.to_str().unwrap(), ""); + assert_golden("complement__opts_counter_melody", &out); + + assert!(dst.exists(), "complement must have written the output file"); + fs::remove_file(&dst).ok(); +} + +/// Pins the invalid-mode failure path: a clear argument error, no output file. +#[test] +fn complement_invalid_mode_golden() { + let src = fixture_path("simple_4_4"); + let dst = env::temp_dir().join("griff_s0_complement_invalid.mid"); + fs::remove_file(&dst).ok(); + + let out = griff( + &[ + "complement", + src.to_str().unwrap(), + dst.to_str().unwrap(), + "--mode", + "bad_mode", + ], + dst.to_str(), + ); + let out = out.replace(src.to_str().unwrap(), ""); + assert_golden("error__complement_invalid_mode", &out); + + assert!( + !dst.exists(), + "complement must not write an output file when the mode is invalid" + ); +} + /// A missing input file is observable CLI behavior worth pinning. #[test] fn missing_file_golden() { diff --git a/cli/tests/snapshots/complement__multi_track.txt b/cli/tests/snapshots/complement__multi_track.txt new file mode 100644 index 00000000..d946175e --- /dev/null +++ b/cli/tests/snapshots/complement__multi_track.txt @@ -0,0 +1,5 @@ +$ griff complement +exit: 0 +--- stdout --- +complement (rhythm_lock, seed 0) — part B appended as track 2 (276 bytes) -> +--- stderr --- diff --git a/cli/tests/snapshots/complement__opts_counter_melody.txt b/cli/tests/snapshots/complement__opts_counter_melody.txt new file mode 100644 index 00000000..f3ed0cef --- /dev/null +++ b/cli/tests/snapshots/complement__opts_counter_melody.txt @@ -0,0 +1,5 @@ +$ griff complement --mode counter_melody --seed 42 --offset -12 +exit: 0 +--- stdout --- +complement (counter_melody, seed 42) — part B appended as track 1 (246 bytes) -> +--- stderr --- diff --git a/cli/tests/snapshots/complement__seven_eight.txt b/cli/tests/snapshots/complement__seven_eight.txt new file mode 100644 index 00000000..97121b70 --- /dev/null +++ b/cli/tests/snapshots/complement__seven_eight.txt @@ -0,0 +1,5 @@ +$ griff complement +exit: 0 +--- stdout --- +complement (rhythm_lock, seed 0) — part B appended as track 1 (192 bytes) -> +--- stderr --- diff --git a/cli/tests/snapshots/complement__simple_4_4.txt b/cli/tests/snapshots/complement__simple_4_4.txt new file mode 100644 index 00000000..58dd3603 --- /dev/null +++ b/cli/tests/snapshots/complement__simple_4_4.txt @@ -0,0 +1,5 @@ +$ griff complement +exit: 0 +--- stdout --- +complement (rhythm_lock, seed 0) — part B appended as track 1 (282 bytes) -> +--- stderr --- diff --git a/cli/tests/snapshots/complement__tempo_change.txt b/cli/tests/snapshots/complement__tempo_change.txt new file mode 100644 index 00000000..7197eb94 --- /dev/null +++ b/cli/tests/snapshots/complement__tempo_change.txt @@ -0,0 +1,5 @@ +$ griff complement +exit: 0 +--- stdout --- +complement (rhythm_lock, seed 0) — part B appended as track 1 (290 bytes) -> +--- stderr --- diff --git a/cli/tests/snapshots/complement__two_phrases.txt b/cli/tests/snapshots/complement__two_phrases.txt new file mode 100644 index 00000000..1b37d7d9 --- /dev/null +++ b/cli/tests/snapshots/complement__two_phrases.txt @@ -0,0 +1,5 @@ +$ griff complement +exit: 0 +--- stdout --- +complement (rhythm_lock, seed 0) — part B appended as track 1 (500 bytes) -> +--- stderr --- diff --git a/cli/tests/snapshots/error__complement_invalid_mode.txt b/cli/tests/snapshots/error__complement_invalid_mode.txt new file mode 100644 index 00000000..76bec7bd --- /dev/null +++ b/cli/tests/snapshots/error__complement_invalid_mode.txt @@ -0,0 +1,5 @@ +$ griff complement --mode bad_mode +exit: 1 +--- stdout --- +--- stderr --- +error: argument error: unknown complement mode 'bad_mode' (try rhythm_lock, register_contrast, call_response, support_layer, octave_double, counter_melody)