use super::*;
use crate::{ParseOptions, export_musicxml, parse_document};

#[test]
fn simple_score_emits_partwise_and_core_attributes() {
    let export =
        export_musicxml("X:1\nT:Scale\nM:6/8\nL:1/8\nK:G\nC2 z x|\n").expect("score should export");

    assert_balanced_xml(&export.musicxml);
    assert!(export.musicxml.contains("<score-partwise version=\"4.0\">"));
    assert!(export.musicxml.contains("<score-part id=\"P1\">"));
    assert!(export.musicxml.contains("<part id=\"P1\">"));
    assert!(export.musicxml.contains("<part-name>Scale</part-name>"));
    assert!(export.musicxml.contains("<divisions>8</divisions>"));
    assert!(export.musicxml.contains("<fifths>1</fifths>"));
    assert!(export.musicxml.contains("<beats>6</beats>"));
    assert!(export.musicxml.contains("<beat-type>8</beat-type>"));
    assert!(export.musicxml.contains("<sign>G</sign>"));
    assert!(export.musicxml.contains("<duration>8</duration>"));
    assert!(export.musicxml.contains("<type>quarter</type>"));
    assert!(export.musicxml.contains("<type>eighth</type>"));
    assert!(export.musicxml.contains("<note print-object=\"no\">"));
}

#[test]
fn text_output_is_escaped_for_metadata_lyrics_harmony_and_directions() {
    let source = concat!(
        "X:1\n",
        "T:A&B <T> \"Q\" 'R'\n",
        "C:Comp & < > \" '\n",
        "Q:Fast & < > \" '\n",
        "L:1/8\n",
        "K:C\n",
        "\"G7&<>'\"\"^Ann & < > '\"C D|\n",
        "w: lyr&<>' two\n",
        "%%foo dir & < > \" '\n",
    );
    let export = export_musicxml(source).expect("escaped text score should export");

    assert_balanced_xml(&export.musicxml);
    assert!(
        export
            .musicxml
            .contains("A&amp;B &lt;T&gt; &quot;Q&quot; &apos;R&apos;")
    );
    assert!(
        export
            .musicxml
            .contains("Comp &amp; &lt; &gt; &quot; &apos;")
    );
    assert!(
        export
            .musicxml
            .contains("Fast &amp; &lt; &gt; &quot; &apos;")
    );
    // `G7&<>'` has junk after the quality token, so (like abc2xml) it is
    // not a recognised chord symbol and is emitted as escaped words.
    assert!(export.musicxml.contains("G7&amp;&lt;&gt;&apos;"));
    assert!(export.musicxml.contains("Ann &amp; &lt; &gt; &apos;"));
    assert!(export.musicxml.contains("lyr&amp;&lt;&gt;&apos;"));
    // Preserved `%%` stylesheet directives are playback/formatting only and
    // are not rendered as printed words, so `%%foo` must not leak out.
    assert!(!export.musicxml.contains("%%foo"));
}

#[test]
fn slash_chord_symbols_export_bass_step_and_alter() {
    // `Db/Ab` uses `b` as the root/bass flat (abc2xml's chord accidental);
    // `-` is reserved for the minor quality and is not a flat.
    let source = "X:1\nT:Slash Chords\nM:4/4\nL:1/4\nK:C\n\"C/E\"C \"Db/Ab\"D|\n";
    let export = export_musicxml(source).expect("slash chords should export");

    assert_balanced_xml(&export.musicxml);
    assert!(export.musicxml.contains("<root-step>C</root-step>"));
    assert!(export.musicxml.contains("<bass-step>E</bass-step>"));
    assert!(export.musicxml.contains("<root-step>D</root-step>"));
    assert!(export.musicxml.contains("<root-alter>-1</root-alter>"));
    assert!(export.musicxml.contains("<bass-step>A</bass-step>"));
    assert!(export.musicxml.contains("<bass-alter>-1</bass-alter>"));
}

#[test]
fn harmony_text_carriers_bind_to_each_chord_symbol() {
    let source = concat!(
        "X:1\n",
        "M:4/4\n",
        "L:1/4\n",
        "K:C\n",
        "[I:croma-harmony-text textless=1]\"A\"",
        "[I:croma-harmony-text textless=1]\"B\"C4|\n",
    );
    let export = export_musicxml(source).expect("harmony text carriers should export");

    assert_balanced_xml(&export.musicxml);
    assert_eq!(count(&export.musicxml, "<harmony>"), 2);
    assert_eq!(count(&export.musicxml, "<kind>major</kind>"), 2);
    assert!(!export.musicxml.contains("<kind text=\"A\">major</kind>"));
    assert!(!export.musicxml.contains("<kind text=\"B\">major</kind>"));
}

#[test]
fn malformed_quoted_chord_strings_export_as_words_not_fake_harmony() {
    let source = "X:1\nM:4/4\nL:1/4\nK:C\n\"(A7)\"C \"C/\"D|\n";
    let export = export_musicxml(source).expect("malformed chord text should export");

    assert_balanced_xml(&export.musicxml);
    assert_eq!(count(&export.musicxml, "<harmony>"), 0);
    assert_eq!(count(&export.musicxml, "<root-step>"), 0);
    assert!(export.musicxml.contains("<words>(A7)</words>"));
    assert!(export.musicxml.contains("<words>C/</words>"));
}

#[test]
fn leading_whitespace_valid_chord_symbols_still_export_harmony() {
    let source = "X:1\nM:4/4\nL:1/4\nK:C\n\"  G7\"C \" C/E\"D|\n";
    let export = export_musicxml(source).expect("valid spaced chords should export");

    assert_balanced_xml(&export.musicxml);
    assert_eq!(count(&export.musicxml, "<harmony>"), 2);
    assert!(export.musicxml.contains("<root-step>G</root-step>"));
    assert!(export.musicxml.contains("<bass-step>E</bass-step>"));
    assert!(!export.musicxml.contains("<words>G7</words>"));
    assert!(!export.musicxml.contains("<words>C/E</words>"));
}

#[test]
fn chord_symbol_before_barline_binds_to_next_note() {
    // ABC 2.1 §4.18: a chord symbol applies to the note it precedes. A barline
    // between the symbol and its note is a measure boundary, not a void:
    // `"F"| c` must keep the F harmony (bound to the c across the bar).
    let source = "X:1\nM:4/4\nL:1/4\nK:C\nC4 \"F\"| c4 |]\n";
    let export = export_musicxml(source).expect("chord symbol at barline should export");

    assert_balanced_xml(&export.musicxml);
    assert_eq!(count(&export.musicxml, "<harmony>"), 1);
    assert!(export.musicxml.contains("<root-step>F</root-step>"));
}

#[test]
fn non_harmony_quoted_text_before_barline_binds_to_barline_position() {
    let source = "X:1\nM:4/4\nL:1/4\nK:C\nC D E \"I\"| F G A B|\n";
    let export = export_musicxml(source).expect("barline words should export");

    assert_balanced_xml(&export.musicxml);
    let measure1 = measure_body(&export.musicxml, "1");
    let measure2 = measure_body(&export.musicxml, "2");
    assert!(
        measure1.contains("<words>I</words>"),
        "barline-bound words should stay in measure 1: {measure1}"
    );
    assert!(
        !measure2.contains("<words>I</words>"),
        "barline-bound words must not move to measure 2: {measure2}"
    );
}

#[test]
fn orphan_chord_symbol_between_double_bars_anchors_to_its_note_less_measure() {
    // tune_003874: an orphan chord symbol `"B"` sits in a note-less measure
    // between two `||`. With no following note in its own measure, per
    // §4.18/§4.19 it must anchor to that measure's bar line, not defer across it
    // to the next note. Anchoring also un-empties the measure, so the second
    // `||` emits its `light-light` right barline instead of being dropped.
    let source = "X:1\nM:2/4\nL:1/8\nK:C\nGA Bc | DD EF ||\n\"B\"\n||\"Gm\"G4 |]\n";
    let export = export_musicxml(source).expect("orphan chord should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(measure_numbers(&measures), vec!["1", "2", "3", "4"]);
    let m3 = measure_body(&export.musicxml, "3");
    assert!(
        m3.contains("<root-step>B</root-step>"),
        "orphan `\"B\"` must anchor to its own measure m3: {m3}"
    );
    assert!(
        has_barline(&measures[2], "right", Some("light-light"), None),
        "the second `||` must emit light-light on the orphan measure: {}",
        export.musicxml
    );
    let m4 = measure_body(&export.musicxml, "4");
    assert!(
        m4.contains("<root-step>G</root-step>"),
        "the `\"Gm\"` chord stays on m4 with its note: {m4}"
    );
    assert_eq!(note_steps(&measures[3]), vec!['G']);
}

#[test]
fn annotation_on_note_less_spacer_measure_stays_on_that_measure() {
    // tune_003230: a `y`-spacer measure (`"Final."y8 |`) is note-less — the `y`
    // spacer creates no rest (§6.1.2). Its only content is the annotation, which
    // must anchor to that measure's bar line, not defer to the next note's
    // measure.
    let source = "X:1\nM:2/4\nL:1/8\nK:C\nGA Bc | cc dd |] \"Final.\"y8 | f4 |]\n";
    let export = export_musicxml(source).expect("spacer annotation should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    let m3 = measure_body(&export.musicxml, "3");
    let m4 = measure_body(&export.musicxml, "4");
    assert!(
        m3.contains("<words>Final.</words>"),
        "annotation must stay on the note-less spacer measure m3: {m3}"
    );
    assert!(
        !m4.contains("<words>Final.</words>"),
        "annotation must NOT defer to the `f` measure m4: {m4}"
    );
    assert_eq!(note_steps(&measures[3]), vec!['F']);
}

#[test]
fn chord_symbol_at_line_end_binds_to_note_on_next_line() {
    // A code line break is not a musical boundary (§6.1.1): `"Em7"` at the end
    // of one music line binds to the first note of the next line.
    let source = "X:1\nM:4/4\nL:1/4\nK:C\nC D \"Em7\"\nE F|\n";
    let export = export_musicxml(source).expect("line-end chord symbol should export");

    assert_balanced_xml(&export.musicxml);
    assert_eq!(count(&export.musicxml, "<harmony>"), 1);
    assert!(export.musicxml.contains("<root-step>E</root-step>"));
    assert!(
        export
            .musicxml
            .contains("<kind text=\"Em7\">minor-seventh</kind>")
    );
}

#[test]
fn annotation_alone_on_line_binds_to_first_following_note() {
    // An annotation on a line of its own (tune_000377's `"Single Reel"`)
    // positions relative to the following note (§4.19) and must not be lost.
    let source = "X:1\nM:C|\nK:G\n\"Single Reel\"\nB2 e2 g2 f2|\n";
    let export = export_musicxml(source).expect("standalone annotation line should export");

    assert_balanced_xml(&export.musicxml);
    assert!(export.musicxml.contains("<words>Single Reel</words>"));
}

#[test]
fn chord_symbol_before_multimeasure_rest_is_kept() {
    // `"Dm"Z2` — the symbol precedes a multi-measure rest; it binds to that
    // rest event, not to nothing.
    let source = "X:1\nM:4/4\nL:1/4\nK:C\n\"Dm\"Z2 | C4 |]\n";
    let export = export_musicxml(source).expect("chord before multirest should export");

    assert_balanced_xml(&export.musicxml);
    assert_eq!(count(&export.musicxml, "<harmony>"), 1);
    assert!(export.musicxml.contains("<root-step>D</root-step>"));
}

#[test]
fn multimeasure_rest_exports_real_full_measure_rests() {
    // ABC 2.1 §4.5: `Z2` means two measures of rest. MusicXML has no legal
    // one-measure overlong rest encoding; write real measures and use
    // `<multiple-rest>` only as display metadata.
    let source = "X:1\nM:4/4\nL:1/4\nK:C\nCDEF|Z2|GABc|]\n";
    let export = export_musicxml(source).expect("multirest should export");

    assert_balanced_xml(&export.musicxml);
    assert_eq!(count(&export.musicxml, "<measure number="), 4);
    assert_eq!(count(&export.musicxml, "<rest measure=\"yes\"/>"), 2);
    assert!(export.musicxml.contains("<multiple-rest>2</multiple-rest>"));
    assert!(!export.musicxml.contains("<time-modification>"));
}

#[test]
fn full_measure_rest_omits_fabricated_type_for_inexpressible_duration() {
    // tune_006472: a 5/4 full-measure `z5` rest needs a MusicXML duration and
    // voice, but should not fabricate a breve plus 8:5 time-modification just
    // to spell an inexpressible measure rest display type.
    let source = "X:1\nM:5/4\nL:1/4\nK:C\nz5|\n";
    let export = export_musicxml(source).expect("full-measure rest should export");

    assert_balanced_xml(&export.musicxml);
    let measure_rest = musicxml_note_blocks(&export.musicxml)
        .into_iter()
        .find(|note| note.contains("<rest measure=\"yes\"/>"))
        .expect("expected a measure rest note");
    assert!(measure_rest.contains("<duration>40</duration>"));
    assert!(measure_rest.contains("<voice>1</voice>"));
    assert!(!measure_rest.contains("<type>"));
    assert!(!measure_rest.contains("<time-modification>"));
}

#[test]
fn chord_member_in_tuplet_keeps_time_modification() {
    // Every note in a tuplet -- including a chord MEMBER -- needs its own
    // `<time-modification>`. write_chord gave members only their own (empty)
    // `tuplets`, so a member emitted no `<time-modification>` and dropped the ratio
    // on re-export, while the head (which carries the chord's tuplet) kept it (PDMX
    // pdmx_0844, pdmx_1006). Members must inherit the chord's ratio -- but NOT the
    // `<tuplet>` notation bracket, which stays on the head only.
    let source = "X:1\nM:4/4\nL:1/4\nK:C\n(3[CEG][DFA][EGB] z2 |\n";
    let export = export_musicxml(source).expect("triplet of chords should export");

    assert_balanced_xml(&export.musicxml);
    let member_blocks: Vec<_> = musicxml_note_blocks(&export.musicxml)
        .into_iter()
        .filter(|note| note.contains("<chord/>"))
        .collect();
    assert!(
        !member_blocks.is_empty(),
        "expected chord-member notes:\n{}",
        export.musicxml
    );
    for member in &member_blocks {
        assert!(
            member.contains("<time-modification>"),
            "a chord member in a tuplet must keep its <time-modification>:\n{member}"
        );
        // The tuplet BRACKET belongs to the head, not the members.
        assert!(
            !member.contains("<tuplet "),
            "a chord member must NOT carry the <tuplet> notation bracket:\n{member}"
        );
    }
}

#[test]
fn partial_nondyadic_rest_omits_fabricated_tuplet() {
    // pdmx_1398: a NON-full-measure rest whose duration has no plain note-type
    // (`z5/2` = 5/2 quarters) must not fabricate a tuplet `<time-modification>`.
    // The omit guard previously covered only full-measure rests, so a partial
    // inexpressible rest fell through `note_spelling` into the fabrication loop and
    // gained a phantom breve + 8:5. A bare `<rest>` with only `<duration>` is the
    // faithful spelling (no real tuplet is present here).
    let source = "X:1\nM:8/4\nL:1/4\nK:C\nA4 z5/2 z3/2|\n";
    let export = export_musicxml(source).expect("partial non-dyadic rest should export");

    assert_balanced_xml(&export.musicxml);
    assert!(
        !export.musicxml.contains("<time-modification>"),
        "a non-dyadic partial rest must not fabricate a tuplet (no real tuplet present):\n{}",
        export.musicxml
    );
    // The inexpressible 5/2 rest is bare (duration only, no synthesized type); the
    // dyadic neighbours still spell normally.
    let bare_rest = musicxml_note_blocks(&export.musicxml)
        .into_iter()
        .find(|note| note.contains("<rest/>") && !note.contains("<type>"));
    assert!(
        bare_rest.is_some(),
        "the 5/2 rest must export as a bare typeless <rest/>:\n{}",
        export.musicxml
    );
}

#[test]
fn dangling_quoted_text_at_tune_end_warns_instead_of_silent_drop() {
    // Quoted text with no following timed event cannot bind to anything; it
    // must surface as a diagnostic, never vanish silently.
    let source = "X:1\nM:4/4\nL:1/4\nK:C\nC4 \"X7\" |]\n";
    let export = export_musicxml(source).expect("dangling quoted text should still export");

    assert_balanced_xml(&export.musicxml);
    assert_eq!(count(&export.musicxml, "<harmony>"), 0);
    assert!(
        export
            .diagnostics
            .iter()
            .any(|d| d.code == "abc.music.dangling_quoted_text"),
        "expected dangling-quoted-text diagnostic, got: {:?}",
        export
            .diagnostics
            .iter()
            .map(|d| d.code)
            .collect::<Vec<_>>()
    );
}

#[test]
fn tuplet_member_written_type_factors_out_the_time_modification() {
    // ABC 2.1 §4.13: a (4 quadruplet in 6/8 plays 4 notes in the time of 3.
    // The WRITTEN type is the de-tupletted duration (eighth + 4:3), not a
    // re-spelling of the sounding duration (dotted 16th + 4:3, internally
    // inconsistent — tune_005074 family).
    let source = "X:1\nM:6/8\nL:1/8\nK:C\nCCC (4BABd|\n";
    let export = export_musicxml(source).expect("quadruplet should export");

    assert_balanced_xml(&export.musicxml);
    assert_eq!(count(&export.musicxml, "<type>eighth</type>"), 7);
    assert!(!export.musicxml.contains("<type>16th</type>"));
    assert_eq!(count(&export.musicxml, "<actual-notes>4</actual-notes>"), 4);

    // Broken rhythm inside a triplet: `(3F>GG` — the lengthened F is a
    // dotted eighth under 3:2, not a dotless eighth.
    let source = "X:1\nM:4/4\nL:1/8\nK:C\n(3F>GG ABcd z2|\n";
    let export = export_musicxml(source).expect("broken triplet should export");
    assert_balanced_xml(&export.musicxml);
    let f_note = export
        .musicxml
        .split("<step>F</step>")
        .nth(1)
        .expect("F note present");
    let f_note = &f_note[..f_note.find("</note>").expect("note closes")];
    assert!(f_note.contains("<type>eighth</type>"));
    assert!(
        f_note.contains("<dot/>"),
        "lengthened F keeps its dot: {f_note}"
    );
}

#[test]
fn very_long_durations_spell_as_long_and_maxima() {
    // MusicXML note-type-value includes long (4 wholes) and maxima (8); croma
    // capped at breve, mis-typing early-music note values (tune_000386
    // family, 31 files).
    let source = "X:1\nM:none\nL:1/4\nK:C\nC16 C32|\n";
    let export = export_musicxml(source).expect("long values should export");

    assert_balanced_xml(&export.musicxml);
    assert!(export.musicxml.contains("<type>long</type>"));
    assert!(export.musicxml.contains("<type>maxima</type>"));
}

#[test]
fn chord_member_slurs_attach_to_their_chords() {
    // ABC 2.1 §4.11: "Both ties and slurs may be used into, out of and
    // between chords". `[(C2(E2] [C2)E2)]` opens two slurs on the first
    // chord and closes both on the second (tune_011938/011866/004626
    // family) — croma dropped them with unknown-chord-token warnings.
    let source = "X:1\nM:4/4\nL:1/4\nK:C\n[(C2(E2] [C2)E2)]|\n";
    let export = export_musicxml(source).expect("chord-member slurs should export");

    assert_balanced_xml(&export.musicxml);
    assert_eq!(count(&export.musicxml, "<slur"), 4);
    assert_eq!(count(&export.musicxml, "type=\"start\""), 2);
    assert_eq!(count(&export.musicxml, "type=\"stop\""), 2);
    assert!(
        !export
            .diagnostics
            .iter()
            .any(|d| d.code == "abc.music.unknown_chord_token")
    );
}

#[test]
fn voice_slur_closing_on_chord_attaches_stop_to_chord_head() {
    // ABC 2.1 §4.11: a voice-level slur may close "out of and between chords".
    // `(C [EG])` closes the slur on the chord [EG]. The matching slur START
    // anchors on the chord head (its first note), so the STOP must land on that
    // same chord head — the note WITHOUT <chord/> — not on the trailing chord
    // member. This matches the original MusicXML and abc2xml. croma used to
    // attach the stop to the last chord member (the PDMX reader-roundtrip slur
    // cluster, ~35 files).
    let source = "X:1\nM:4/4\nL:1/4\nK:C\n(C [EG])|\n";
    let export = export_musicxml(source).expect("chord-closing slur should export");

    assert_balanced_xml(&export.musicxml);
    assert_eq!(count(&export.musicxml, "<slur type=\"start\""), 1);
    assert_eq!(count(&export.musicxml, "<slur type=\"stop\""), 1);

    // The stop sits inside the chord head: the nearest <note before the stop
    // marker must not be a chord member (no <chord/>).
    let stop_index = export
        .musicxml
        .find("<slur type=\"stop\"")
        .expect("slur stop should be present");
    let note_start = export.musicxml[..stop_index]
        .rfind("<note")
        .expect("slur stop should be inside a note");
    let stop_note = &export.musicxml[note_start..stop_index];
    assert!(
        !stop_note.contains("<chord/>"),
        "slur stop should land on the chord head, not a chord member: {stop_note}"
    );
}

#[test]
fn cross_voice_slur_carrier_exports_slur_start_and_stop_in_different_voices() {
    // A MusicXML slur whose start and stop live in DIFFERENT voices cannot be
    // spelled with ABC `(`/`)` (those pair within one `V:` stream). croma
    // carries it as `[I:croma-xvoice-slur pair=N role=start|stop]` on each end.
    // On export the start carrier in voice 1 and the stop carrier in voice 2
    // must each re-emit a `<slur>` on their note — and a normal same-voice
    // `(DE)` slur must still export unchanged.
    // `%%score (1 2)` groups both voices into ONE part, mirroring pdmx_0998's
    // single part with a slur from voice 1 into voice 2.
    let source = concat!(
        "X:1\n%%score (1 2)\nM:4/4\nL:1/4\nK:C\n",
        "V:1\n[I:croma-xvoice-slur pair=1 role=start]C (DE) F |\n",
        "V:2\n[I:croma-xvoice-slur pair=1 role=stop]G A B z |\n",
    );
    let export = export_musicxml(source).expect("cross-voice slur carrier should export");

    assert_balanced_xml(&export.musicxml);
    // Two slur starts (cross-voice on C, same-voice on D) and two stops
    // (same-voice on E, cross-voice on G).
    assert_eq!(count(&export.musicxml, "<slur type=\"start\""), 2);
    assert_eq!(count(&export.musicxml, "<slur type=\"stop\""), 2);

    // The cross-voice start lands on C in voice 1.
    let c_note = note_block(&export.musicxml, "C");
    assert!(
        c_note.contains("<slur type=\"start\"") && c_note.contains("<voice>1</voice>"),
        "cross-voice slur start must be on the C note in voice 1: {c_note}"
    );
    // The cross-voice stop lands on G in voice 2 — a different voice.
    let g_note = note_block(&export.musicxml, "G");
    assert!(
        g_note.contains("<slur type=\"stop\"") && g_note.contains("<voice>2</voice>"),
        "cross-voice slur stop must be on the G note in voice 2: {g_note}"
    );
    // The same-voice `(DE)` slur is untouched: D opens, E closes.
    assert!(note_block(&export.musicxml, "D").contains("<slur type=\"start\""));
    assert!(note_block(&export.musicxml, "E").contains("<slur type=\"stop\""));
    // The carrier is consumed, not echoed as a leftover display directive.
    assert!(!export.musicxml.contains("croma-xvoice-slur"));
    // The cross-voice start and stop must share the same `number=` so the slur
    // is a valid pair across voices (the source's matching number).
    assert_eq!(
        slur_number(c_note, "start"),
        slur_number(g_note, "stop"),
        "cross-voice slur ends must carry matching slur numbers"
    );
}

/// The `number="N"` attribute of the `<slur type="{role}"...>` in `note`.
fn slur_number(note: &str, role: &str) -> String {
    let marker = format!("<slur type=\"{role}\"");
    let at = note.find(&marker).expect("slur marker present");
    let tag = &note[at..note[at..].find("/>").expect("slur self-closes") + at];
    let key = "number=\"";
    let n = tag.find(key).expect("slur has a number") + key.len();
    tag[n..tag[n..].find('"').expect("number closes") + n].to_owned()
}

/// The `<note>...</note>` block whose `<pitch>` carries `<step>{step}</step>`.
fn note_block<'a>(xml: &'a str, step: &str) -> &'a str {
    let needle = format!("<step>{step}</step>");
    let at = xml
        .find(&needle)
        .unwrap_or_else(|| panic!("no note with step {step}"));
    let start = xml[..at].rfind("<note").expect("step inside a note");
    let end = xml[at..].find("</note>").expect("note must close") + at + "</note>".len();
    &xml[start..end]
}

#[test]
fn lowercase_root_quoted_text_is_words_not_harmony() {
    // ABC 2.1 §4.18: the chord root is A-G (uppercase); only the bass note
    // may be lowercase. "d" is annotation text, not a D chord (40 corpus
    // files). A lowercase BASS after / stays a chord.
    let source = "X:1\nM:4/4\nL:1/4\nK:C\n\"d\"C D \"D/f\"E F|\n";
    let export = export_musicxml(source).expect("lowercase root should export");

    assert_balanced_xml(&export.musicxml);
    assert_eq!(count(&export.musicxml, "<harmony>"), 1);
    assert!(export.musicxml.contains("<words>d</words>"));
    assert!(export.musicxml.contains("<bass-step>F</bass-step>"));
}

#[test]
fn body_tempo_field_emits_metronome_direction() {
    // ABC 2.1 §3.1.8 and the field table allow Q: in the tune body
    // (tune_007548 family, ~120 files): a `Q:` line after K: must produce a
    // <metronome> direction, not vanish silently.
    let source = "X:1\nM:4/4\nL:1/4\nK:C\nQ:1/4=132\nCDEF|GABc|]\n";
    let export = export_musicxml(source).expect("body tempo should export");

    assert_balanced_xml(&export.musicxml);
    assert!(export.musicxml.contains("<per-minute>132</per-minute>"));
    assert!(export.musicxml.contains("<beat-unit>quarter</beat-unit>"));
}

#[test]
fn mid_tune_tempo_change_lands_at_its_measure() {
    // A tempo change between music lines positions at the point of change,
    // not at the start of the tune.
    let source = "X:1\nM:4/4\nL:1/4\nK:C\nQ:1/4=100\nCDEF|\nQ:1/4=160\nGABc|]\n";
    let export = export_musicxml(source).expect("mid-tune tempo should export");

    assert_balanced_xml(&export.musicxml);
    assert!(export.musicxml.contains("<per-minute>100</per-minute>"));
    assert!(export.musicxml.contains("<per-minute>160</per-minute>"));
    let measure2 = export
        .musicxml
        .split("<measure number=\"2\">")
        .nth(1)
        .expect("measure 2 exists");
    assert!(measure2.contains("<per-minute>160</per-minute>"));
}

#[test]
fn liberal_barline_runs_keep_their_strongest_meaning() {
    // ABC 2.1 §4.8: "bar lines may have any shape, using a sequence of
    // |, [, ] and :". Croma recognized such runs as boundaries but erased
    // their meaning from the output (tune_012890/004928/003884 families).
    // A `]`-bearing run is a final (thin-thick) bar; leading repeat dots are
    // a backward repeat; a `|:`-leading run is a forward repeat.
    let final_run =
        export_musicxml("X:1\nM:4/4\nL:1/4\nK:C\nCDEF|GABc||]\n").expect("||] should export");
    assert_balanced_xml(&final_run.musicxml);
    assert!(
        final_run
            .musicxml
            .contains("<bar-style>light-heavy</bar-style>")
    );

    let leading_dots = export_musicxml("X:1\nM:4/4\nL:1/4\nK:C\n|:CDEF|GABc:[|]|]\n")
        .expect(":[|]|] should export");
    assert_balanced_xml(&leading_dots.musicxml);
    assert!(
        leading_dots
            .musicxml
            .contains("<repeat direction=\"backward\"/>")
    );

    let sandwich = export_musicxml("X:1\nM:4/4\nL:1/4\nK:C\n|:CDEF:|\n|:|GABc:|\n")
        .expect("|:| should export");
    assert_balanced_xml(&sandwich.musicxml);
    assert_eq!(
        count(&sandwich.musicxml, "<repeat direction=\"forward\"/>"),
        2
    );
}

#[test]
fn trailing_colon_after_final_bar_is_a_repeat_start_not_a_phantom_measure() {
    // `|]:` before a new section (tune_012511 family): the colon is the
    // bar's trailing repeat dots — a forward repeat for what follows, never
    // a separate one-colon measure.
    let source = "X:1\nM:4/4\nL:1/4\nK:C\nCDEF|]:GABc|]\n";
    let export = export_musicxml(source).expect("|]: should export");

    assert_balanced_xml(&export.musicxml);
    assert_eq!(count(&export.musicxml, "<measure number="), 2);
    assert_eq!(
        count(&export.musicxml, "<repeat direction=\"forward\"/>"),
        1
    );
}

#[test]
fn line_leading_colon_after_repeat_end_does_not_emit_phantom_measure() {
    // tune_015346: a line-leading `:` after a previous `:|` marks the next
    // repeated section. It must attach to the following pickup, not emit an
    // empty measure between the repeat end and `gf`.
    let source = "X:1\nM:4/4\nL:1/8\nK:G\nGA|G2GGG2:|\n:gf|e2ddd2GA|\n";
    let export = export_musicxml(source).expect("line-leading colon should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(measure_numbers(&measures), vec!["1", "2", "3", "4"]);
    assert_eq!(note_steps(&measures[2]), vec!['G', 'F']);
    assert!(has_barline(
        &measures[1],
        "right",
        Some("light-heavy"),
        Some("backward")
    ));
    assert!(has_barline(
        &measures[2],
        "left",
        Some("heavy-light"),
        Some("forward")
    ));
}

#[test]
fn mid_line_spaced_colon_is_not_a_forward_repeat() {
    // tune_003603: a lone `:` MID-LINE with a space before and a note after
    // (`... A4 :D ...`) is a stray repeat dot, NOT a line-leading section-start
    // `:`. croma fabricated a `heavy-light` forward repeat for it via the
    // line-leading-colon path, which fired on "whitespace immediately before"
    // rather than "truly at the line start". Only a `:` with nothing but
    // whitespace before it on the line opens a repeat.
    let source = "X:1\nM:4/4\nL:1/4\nK:C\nC D E F :G A B c|]\n";
    let export = export_musicxml(source).expect("mid-line colon should export");

    assert_balanced_xml(&export.musicxml);
    assert_eq!(
        count(&export.musicxml, "<repeat direction=\"forward\"/>"),
        0,
        "a mid-line spaced `:` must not fabricate a forward repeat: {}",
        export.musicxml
    );
}

#[test]
fn line_leading_colon_after_section_final_does_not_emit_phantom_measure() {
    // tune_014930: a line-leading `:` after a section-final `|]` marks the
    // next repeated section. It must attach to the following notes, not emit
    // an empty measure between the section end and `d d/e/`.
    let source = "X:1\nM:2/4\nL:1/8\nK:C\nGA|]\n:d d/e/|d c/B/|cA:|\n";
    let export = export_musicxml(source).expect("section-leading colon should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(measure_numbers(&measures), vec!["1", "2", "3", "4"]);
    assert_eq!(note_steps(&measures[1]), vec!['D', 'D', 'E']);
    assert!(has_barline(
        &measures[0],
        "right",
        Some("light-heavy"),
        None
    ));
    assert!(has_barline(
        &measures[1],
        "left",
        Some("heavy-light"),
        Some("forward")
    ));
}

#[test]
fn lone_colon_between_notes_is_a_liberal_measure_boundary() {
    // `CDEF:GABc|`: §4.8's liberal-recognition guidance covers colon runs;
    // dropping the boundary mangled the measure structure (71 files). The
    // boundary is kept (3 measures), warned, and carries no repeat glyph.
    let source = "X:1\nM:4/4\nL:1/4\nK:C\nCDEF:GABc|cdef|]\n";
    let export = export_musicxml(source).expect("lone colon should export");

    assert_balanced_xml(&export.musicxml);
    assert_eq!(count(&export.musicxml, "<measure number="), 3);
    assert!(!export.musicxml.contains("<repeat"));
    assert!(
        export
            .diagnostics
            .iter()
            .any(|d| d.code == "abc.music.barline.liberal")
    );
}

#[test]
fn multi_measure_volta_emits_ending_stop_at_closing_barline() {
    // ABC 2.1 §4.9: "The Nth ending starts with [N and ends with one of
    // ||, :| |] or [|" — endings legally span multiple measures. The
    // MusicXML bracket must close: an <ending type="start"> with no stop is
    // a dangling bracket (tune_008287 family, 905 affected files).
    let source = "X:1\nM:4/4\nL:1/4\nK:C\n|:CDEF|[1 GABc|GGGG:|[2 cdef|]\n";
    let export = export_musicxml(source).expect("multi-measure volta should export");

    assert_balanced_xml(&export.musicxml);
    assert!(
        export
            .musicxml
            .contains("<ending number=\"1\" type=\"start\"/>")
    );
    assert!(
        export
            .musicxml
            .contains("<ending number=\"1\" type=\"stop\"/>")
    );
    assert!(
        export
            .musicxml
            .contains("<ending number=\"2\" type=\"start\"/>")
    );
    assert!(
        export
            .musicxml
            .contains("<ending number=\"2\" type=\"stop\"/>")
    );
}

#[test]
fn volta_ending_closed_by_thick_thin_bar_emits_stop() {
    // ABC 2.1 §4.10: "The Nth ending starts with [N and ends with one of
    // ||, :| |] or [|" — the thick-thin `[|` is a valid ending terminator.
    // croma's stops_repeat_ending_barline omitted BarlineKind::Initial (the kind
    // `[|` parses to), so a 2nd ending closed by `[|` was left a dangling
    // <ending type="start"> with no stop (tune_004922 / tune_002878 family).
    // No trailing final bar here, so without the fix the bracket stays open at
    // the part end and emits no stop at all.
    let source = "X:1\nM:4/4\nL:1/4\nK:C\n|:CDEF|[1 GABc:|[2 cdef[|GGGG\n";
    let export = export_musicxml(source).expect("volta closed by [| should export");

    assert_balanced_xml(&export.musicxml);
    assert!(
        export
            .musicxml
            .contains("<ending number=\"2\" type=\"start\"/>"),
        "ending 2 should start"
    );
    assert!(
        export
            .musicxml
            .contains("<ending number=\"2\" type=\"stop\"/>"),
        "ending 2 must be closed by the `[|` thick-thin terminator (§4.10), not left dangling"
    );
}

#[test]
fn quoted_text_volta_extension_exports_ending_and_harmony() {
    let source = "X:1\nM:4/4\nL:1/8\nK:C\n\"G\"B2A2 G2F2 |[\"cont\" \"Am7\"F2G2 A2B2 | \"D7\"c2d2 \"G\"e4 |]\n";
    let export = export_musicxml(source).expect("quoted-text volta should export");

    assert_balanced_xml(&export.musicxml);
    assert!(
        export
            .musicxml
            .contains("<ending number=\"33\" type=\"start\">cont</ending>"),
        "quoted volta text should export as an ending start:\n{}",
        export.musicxml
    );
    assert!(
        export
            .musicxml
            .contains("<ending number=\"33\" type=\"stop\"/>"),
        "quoted volta text should close at the final barline:\n{}",
        export.musicxml
    );
    assert!(
        !export.musicxml.contains("<ending number=\"cont\""),
        "MusicXML ending-number must stay numeric for readers:\n{}",
        export.musicxml
    );
    assert!(
        export
            .musicxml
            .contains("<kind text=\"Am7\">minor-seventh</kind>"),
        "harmony after the quoted volta label should still bind to F2:\n{}",
        export.musicxml
    );
    assert!(
        !export
            .diagnostics
            .iter()
            .any(|diagnostic| diagnostic.code == "abc.music.unclosed_chord"),
        "quoted volta text must not be parsed as an unclosed chord: {:?}",
        export.diagnostics
    );
}

#[test]
fn volta_unclosed_in_source_stays_open_at_part_end() {
    // ABC 2.1 §4.9 closes an ending at `||`, `:|`, `|]` or `[|`. When the
    // source never writes one (the tune just ends on a plain bar), no stop
    // is synthesized: fabricating a closing barline the source does not have
    // regressed 25 previously-matching corpus files. A closing bar later in
    // the source still stops the bracket (see
    // multi_measure_volta_emits_ending_stop_at_closing_barline).
    let source = "X:1\nM:4/4\nL:1/4\nK:C\n|:CDEF|[1 GABc:|[2 cdef|\n";
    let export = export_musicxml(source).expect("volta to part end should export");

    assert_balanced_xml(&export.musicxml);
    assert!(
        export
            .musicxml
            .contains("<ending number=\"2\" type=\"start\"/>")
    );
    assert!(
        !export
            .musicxml
            .contains("<ending number=\"2\" type=\"stop\"/>")
    );
}

#[test]
fn ending_precedes_repeat_inside_barline_element() {
    // MusicXML's barline content model orders <ending> before <repeat>; the
    // reverse is schema-invalid.
    let source = "X:1\nM:4/4\nL:1/4\nK:C\n|:CDEF|[1 GABc:|[2 cdef|]\n";
    let export = export_musicxml(source).expect("volta should export");

    assert_balanced_xml(&export.musicxml);
    let barline = export
        .musicxml
        .split("<barline location=\"right\">")
        .find(|chunk| chunk.contains("<ending number=\"1\" type=\"stop\"/>"))
        .expect("ending-1 stop barline exists");
    let barline = &barline[..barline.find("</barline>").expect("closed")];
    let ending_pos = barline.find("<ending").expect("ending present");
    let repeat_pos = barline.find("<repeat").expect("repeat present");
    assert!(
        ending_pos < repeat_pos,
        "ending must precede repeat, got: {barline}"
    );
}

#[test]
fn dynamic_decoration_before_barline_binds_to_barline_position() {
    // `!f!|` decorates the following bar line (ABC 2.1 §4.14: decorations may
    // apply to bar lines), so the MusicXML direction must land at the end of
    // the current measure rather than on the first note after the boundary.
    let source = "X:1\nM:4/4\nL:1/4\nK:C\nC D E F !f!|\nG A B c|\n";
    let export = export_musicxml(source).expect("dynamic at barline should export");

    assert_balanced_xml(&export.musicxml);
    assert_eq!(count(&export.musicxml, "<f/>"), 1);
    let measure1 = measure_body(&export.musicxml, "1");
    let measure2 = measure_body(&export.musicxml, "2");
    assert!(measure1.contains("<f/>"));
    assert!(!measure2.contains("<f/>"));
    assert!(
        measure1.rfind("<note>").expect("measure 1 has notes")
            < measure1
                .find("<direction")
                .expect("measure 1 has direction"),
        "barline-bound dynamic should be written after the measure's notes: {measure1}"
    );
}

#[test]
fn annotation_before_barline_binds_to_barline_position() {
    // ABC 2.1 §4.19 explicitly allows annotations to attach to the following
    // bar line. Keep the words at the boundary instead of shifting them to the
    // next measure's first note.
    let source = "X:1\nM:4/4\nL:1/4\nK:C\nC D E F \"_Fine\"|\nG A B c|\n";
    let export = export_musicxml(source).expect("annotation at barline should export");

    assert_balanced_xml(&export.musicxml);
    assert_eq!(count(&export.musicxml, "<words>Fine</words>"), 1);
    let measure1 = measure_body(&export.musicxml, "1");
    let measure2 = measure_body(&export.musicxml, "2");
    assert!(measure1.contains("<words>Fine</words>"));
    assert!(!measure2.contains("<words>Fine</words>"));
    assert!(
        measure1.rfind("<note>").expect("measure 1 has notes")
            < measure1
                .find("<direction")
                .expect("measure 1 has direction"),
        "barline-bound annotation should be written after the measure's notes: {measure1}"
    );
}

#[test]
fn dynamic_decoration_at_line_end_without_barline_binds_to_next_note() {
    // A code line break is not a musical object; without a barline, the
    // decoration still applies to the next note.
    let source = "X:1\nM:4/4\nL:1/4\nK:C\nC D E F |\n!f!\nG A B c|\n";
    let export = export_musicxml(source).expect("line-end dynamic should export");

    assert_balanced_xml(&export.musicxml);
    assert_eq!(count(&export.musicxml, "<f/>"), 1);
    let measure1 = measure_body(&export.musicxml, "1");
    let measure2 = measure_body(&export.musicxml, "2");
    assert!(!measure1.contains("<f/>"));
    assert!(measure2.contains("<f/>"));
}

#[test]
fn grace_group_before_barline_attaches_to_note_across_the_bar() {
    // `{e/}|d3`: ABC 2.1 §4.12 defines no void-at-barline rule for graces and
    // §4.20 orders a grace before the note it decorates — here that note is
    // across the bar. The transcriber's grace must not be silently lost
    // (tune_014161 family).
    let source = "X:1\nL:1/8\nM:6/8\nK:D\nGB2Af2{e/}|d3D2z|]\n";
    let export = export_musicxml(source).expect("grace at barline should export");

    assert_balanced_xml(&export.musicxml);
    assert_eq!(count(&export.musicxml, "<grace"), 1);
    // The grace lands in measure 2, before the principal d.
    let measure2 = export
        .musicxml
        .split("<measure number=\"2\">")
        .nth(1)
        .expect("measure 2 exists");
    assert!(measure2.contains("<grace"));
}

#[test]
fn trailing_grace_group_before_barline_stays_after_previous_note() {
    // abc2xml 268 treats `T e6 {de} |` as a trill termination: the two grace
    // notes remain after the principal E inside measure 1, not as leading
    // graces before the D that opens measure 2.
    let source = "X:1\nT:Trailing Grace\nM:4/4\nL:1/8\nK:C\nTe6{de}|d2f f2f|\n";
    let export = export_musicxml(source).expect("trailing grace should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(measure_numbers(&measures), vec!["1", "2"]);
    assert_eq!(note_steps(&measures[0]), vec!['E', 'D', 'E']);
    assert_eq!(note_grace_flags(&measures[0]), vec![false, true, true]);
    assert_eq!(note_steps(&measures[1]), vec!['D', 'F', 'F', 'F']);
    assert_eq!(
        note_grace_flags(&measures[1]),
        vec![false, false, false, false]
    );
}

#[test]
fn dangling_grace_group_at_tune_end_warns_instead_of_silent_drop() {
    // A grace group with no following note has nothing to decorate; it must
    // surface as a diagnostic, never vanish silently.
    let source = "X:1\nL:1/8\nK:C\nC4 {ab}|]\n";
    let export = export_musicxml(source).expect("dangling grace should still export");

    assert_balanced_xml(&export.musicxml);
    assert_eq!(count(&export.musicxml, "<grace"), 0);
    assert!(
        export
            .diagnostics
            .iter()
            .any(|d| d.code == "abc.music.dangling_grace_group"),
        "expected dangling-grace diagnostic, got: {:?}",
        export
            .diagnostics
            .iter()
            .map(|d| d.code)
            .collect::<Vec<_>>()
    );
}

#[test]
fn chord_symbol_survives_legacy_plus_decoration() {
    // `"F"+>+a4`: the deprecated `+...+` decoration syntax (ABC 2.0 §10.2.2)
    // between the chord symbol and its note must not destroy the symbol —
    // same silent-data-loss family as the fixed grace/slur/tuplet flushes
    // (tune_006965 family).
    let source = "X:1\nM:4/4\nL:1/4\nK:C\n\"F\"+>+a4|\n";
    let export = export_musicxml(source).expect("chord before +decoration+ should export");

    assert_balanced_xml(&export.musicxml);
    assert_eq!(count(&export.musicxml, "<harmony>"), 1);
    assert!(export.musicxml.contains("<root-step>F</root-step>"));
}

#[test]
fn crescendo_and_diminuendo_decorations_emit_wedges_not_words() {
    // ABC 2.1 (lines 1114-1121): !crescendo(!/!<(! open a hairpin,
    // !crescendo)!/!<)! close it (same for diminuendo). These are wedge
    // marks, not printable text — emitting the raw name as <words> mangles
    // the notation.
    let source = concat!(
        "X:1\n",
        "M:4/4\n",
        "L:1/4\n",
        "K:C\n",
        "!<(!C D !<)!E F|!diminuendo(!G A !diminuendo)!B c|\n",
    );
    let export = export_musicxml(source).expect("wedge decorations should export");

    assert_balanced_xml(&export.musicxml);
    assert_eq!(count(&export.musicxml, "<wedge type=\"crescendo\"/>"), 1);
    assert_eq!(count(&export.musicxml, "<wedge type=\"diminuendo\"/>"), 1);
    assert_eq!(count(&export.musicxml, "<wedge type=\"stop\"/>"), 2);
    assert!(!export.musicxml.contains("<words><(</words>"));
    assert!(!export.musicxml.contains("<words>diminuendo(</words>"));
}

#[test]
fn plus_decoration_emits_stopped_technical_not_words() {
    // ABC 2.1 (line 1101): !+! is left-hand pizzicato — a note-attached
    // technical mark (MusicXML <stopped/>, the + glyph), not visible text.
    let source = "X:1\nM:4/4\nL:1/4\nK:C\n!+!C D E F|\n";
    let export = export_musicxml(source).expect("plus decoration should export");

    assert_balanced_xml(&export.musicxml);
    assert_eq!(count(&export.musicxml, "<stopped/>"), 1);
    assert!(!export.musicxml.contains("<words>+</words>"));
}

#[test]
fn fingering_arpeggio_and_slide_decorations_emit_notations_not_words() {
    // ABC 2.1 §4.14 defines !0!-!5! as fingerings, !arpeggio! as a vertical
    // squiggle, and !slide! as a slide up to a note. These are note-attached
    // notations, not detached direction words.
    let source = "X:1\nM:4/4\nL:1/4\nK:C\n!3!C !arpeggio![CEG] !slide!D E|\n";
    let export = export_musicxml(source).expect("decoration notations should export");

    assert_balanced_xml(&export.musicxml);
    assert_eq!(count(&export.musicxml, "<fingering>3</fingering>"), 1);
    assert_eq!(count(&export.musicxml, "<arpeggiate"), 1);
    assert_eq!(count(&export.musicxml, "<scoop/>"), 1);
    for text in ["3", "arpeggio", "slide"] {
        assert!(
            !export.musicxml.contains(&format!("<words>{text}</words>")),
            "{text} decoration should not be emitted as words"
        );
    }
    assert!(
        !export
            .diagnostics
            .iter()
            .any(|diagnostic| diagnostic.code == "abc.musicxml.decoration.unsupported"),
        "supported decorations should not produce unsupported-decoration diagnostics"
    );
}

#[test]
fn extended_articulation_decorations_emit_notations_not_words() {
    // These MusicXML articulation elements have direct empty-element spellings
    // and appear in the PDMX residuals; keep them as note-attached notations.
    let source = "X:1\nM:4/4\nL:1/4\nK:C\n!caesura!z !detached-legato!D !falloff!E !doit!F|\n";
    let export = export_musicxml(source).expect("extended articulations should export");

    assert_balanced_xml(&export.musicxml);
    for tag in ["caesura", "detached-legato", "falloff", "doit"] {
        assert_eq!(
            count(&export.musicxml, &format!("<{tag}/>")),
            1,
            "{tag} should emit as an articulation"
        );
        assert!(
            !export.musicxml.contains(&format!("<words>{tag}</words>")),
            "{tag} decoration should not be emitted as words"
        );
    }
    assert!(
        !export
            .diagnostics
            .iter()
            .any(|diagnostic| diagnostic.code == "abc.musicxml.decoration.unsupported"),
        "supported extended articulations should not warn"
    );
}

#[test]
fn musicxml_tremolo_carriers_emit_ornament_text_not_words() {
    // MusicXML tremolo is value-bearing (slash count + single/start/stop type).
    // Preserve it through explicit ABC carrier decoration names rather than
    // dropping the value or emitting the carrier as visible text.
    let source = concat!(
        "X:1\n",
        "M:4/4\n",
        "L:1/4\n",
        "K:C\n",
        "!musicxml-tremolo-single-2!C !musicxml-tremolo-start-3!D ",
        "!musicxml-tremolo-stop-3!E !musicxml-tremolo-single-1!F|\n",
    );
    let export = export_musicxml(source).expect("tremolo carriers should export");

    assert_balanced_xml(&export.musicxml);
    for (tremolo_type, marks) in [
        ("single", "2"),
        ("start", "3"),
        ("stop", "3"),
        ("single", "1"),
    ] {
        assert!(
            export.musicxml.contains(&format!(
                "<tremolo type=\"{tremolo_type}\">{marks}</tremolo>"
            )),
            "{tremolo_type} tremolo with {marks} marks should emit as ornament text"
        );
    }
    for text in [
        "musicxml-tremolo-single-2",
        "musicxml-tremolo-start-3",
        "musicxml-tremolo-stop-3",
        "musicxml-tremolo-single-1",
    ] {
        assert!(
            !export.musicxml.contains(&format!("<words>{text}</words>")),
            "{text} carrier should not be emitted as words"
        );
    }
    assert!(
        !export
            .diagnostics
            .iter()
            .any(|diagnostic| diagnostic.code == "abc.musicxml.decoration.unsupported"),
        "supported tremolo carriers should not warn"
    );
}

#[test]
fn musicxml_technical_carriers_emit_technical_text_not_words() {
    // String/fret and non-standard fingering text have no native ABC spelling.
    // Preserve them through explicit MusicXML carrier decorations.
    let source = concat!(
        "X:1\n",
        "M:4/4\n",
        "L:1/4\n",
        "K:C\n",
        "!musicxml-tech-string-4!!musicxml-tech-fret-11!C ",
        "!musicxml-tech-fingering-hex-45!D ",
        "!musicxml-tech-fingering-hex-310a320a2d!E F|\n",
    );
    let export = export_musicxml(source).expect("technical carriers should export");

    assert_balanced_xml(&export.musicxml);
    assert_eq!(count(&export.musicxml, "<string>4</string>"), 1);
    assert_eq!(count(&export.musicxml, "<fret>11</fret>"), 1);
    assert_eq!(count(&export.musicxml, "<fingering>E</fingering>"), 1);
    assert_eq!(count(&export.musicxml, "<fingering>1\n2\n-</fingering>"), 1);
    for text in [
        "musicxml-tech-string-4",
        "musicxml-tech-fret-11",
        "musicxml-tech-fingering-hex-45",
        "musicxml-tech-fingering-hex-310a320a2d",
    ] {
        assert!(
            !export.musicxml.contains(&format!("<words>{text}</words>")),
            "{text} carrier should not be emitted as words"
        );
    }
    assert!(
        !export
            .diagnostics
            .iter()
            .any(|diagnostic| diagnostic.code == "abc.musicxml.decoration.unsupported"),
        "supported technical carriers should not warn"
    );
}

#[test]
fn invalid_musicxml_technical_carrier_does_not_emit_invalid_xml_text() {
    let source = concat!(
        "X:1\n",
        "M:4/4\n",
        "L:1/4\n",
        "K:C\n",
        "!musicxml-tech-fingering-hex-01!C D E F|\n",
    );
    let export = export_musicxml(source).expect("invalid carrier should not break export");

    assert_balanced_xml(&export.musicxml);
    assert!(
        !export.musicxml.contains('\u{1}'),
        "invalid XML character from carrier must not reach MusicXML output"
    );
    assert!(
        !export.musicxml.contains("<fingering>"),
        "invalid carrier should not emit a fingering element"
    );
    assert!(
        export
            .diagnostics
            .iter()
            .any(|diagnostic| diagnostic.code == "abc.musicxml.decoration.unsupported"),
        "invalid carrier should fall back to an unsupported-decoration diagnostic"
    );
}

#[test]
fn musicxml_spanner_carriers_emit_notations_not_words() {
    let source = concat!(
        "X:1\n",
        "M:6/4\n",
        "L:1/4\n",
        "K:C\n",
        "!musicxml-glissando-start-wavy-1-hex-676c6973732e!C ",
        "!musicxml-glissando-stop-wavy-1!D ",
        "!musicxml-slide-start-solid-1-hex-676c6973732e!E ",
        "!musicxml-slide-stop-solid-1!F ",
        "!musicxml-wavy-line-start-1!!musicxml-wavy-line-stop-1!G A|\n",
    );
    let export = export_musicxml(source).expect("spanner carriers should export");

    assert_balanced_xml(&export.musicxml);
    assert_eq!(
        count(
            &export.musicxml,
            "<glissando type=\"start\" number=\"1\" line-type=\"wavy\">gliss.</glissando>"
        ),
        1
    );
    assert_eq!(
        count(
            &export.musicxml,
            "<glissando type=\"stop\" number=\"1\" line-type=\"wavy\"/>"
        ),
        1
    );
    assert_eq!(
        count(
            &export.musicxml,
            "<slide type=\"start\" number=\"1\" line-type=\"solid\">gliss.</slide>"
        ),
        1
    );
    assert_eq!(
        count(
            &export.musicxml,
            "<slide type=\"stop\" number=\"1\" line-type=\"solid\"/>"
        ),
        1
    );
    assert_eq!(
        count(&export.musicxml, "<wavy-line type=\"start\" number=\"1\"/>"),
        1
    );
    assert_eq!(
        count(&export.musicxml, "<wavy-line type=\"stop\" number=\"1\"/>"),
        1
    );
    for text in [
        "musicxml-glissando-start-wavy-1-hex-676c6973732e",
        "musicxml-glissando-stop-wavy-1",
        "musicxml-slide-start-solid-1-hex-676c6973732e",
        "musicxml-slide-stop-solid-1",
        "musicxml-wavy-line-start-1",
        "musicxml-wavy-line-stop-1",
    ] {
        assert!(
            !export.musicxml.contains(&format!("<words>{text}</words>")),
            "{text} carrier should not be emitted as words"
        );
    }
    assert!(
        !export
            .diagnostics
            .iter()
            .any(|diagnostic| diagnostic.code == "abc.musicxml.decoration.unsupported"),
        "supported spanner carriers should not warn"
    );
}

#[test]
fn invalid_musicxml_spanner_carrier_does_not_emit_invalid_xml_text() {
    let source = concat!(
        "X:1\n",
        "M:4/4\n",
        "L:1/4\n",
        "K:C\n",
        "!musicxml-glissando-start-wavy-1-hex-01!C D E F|\n",
    );
    let export = export_musicxml(source).expect("invalid carrier should not break export");

    assert_balanced_xml(&export.musicxml);
    assert!(
        !export.musicxml.contains('\u{1}'),
        "invalid XML character from carrier must not reach MusicXML output"
    );
    assert!(
        !export.musicxml.contains("<glissando"),
        "invalid carrier should not emit a glissando element"
    );
    assert!(
        export
            .diagnostics
            .iter()
            .any(|diagnostic| diagnostic.code == "abc.musicxml.decoration.unsupported"),
        "invalid carrier should fall back to an unsupported-decoration diagnostic"
    );
}

#[test]
fn musicxml_tuplet_display_carrier_emits_tuplet_children() {
    let source = concat!(
        "X:1\n",
        "M:4/4\n",
        "L:1/4\n",
        "K:C\n",
        "(4:2:2!musicxml-tuplet-detail-v1-byes-a2-quarter-0-n2-quarter-0!D4 A,4|\n",
    );
    let export = export_musicxml(source).expect("tuplet display carrier should export");

    assert_balanced_xml(&export.musicxml);
    assert!(
        export
            .musicxml
            .contains("<tuplet type=\"start\" bracket=\"yes\">")
    );
    assert!(export.musicxml.contains("<tuplet-actual>"));
    assert!(export.musicxml.contains("<tuplet-number>2</tuplet-number>"));
    assert!(
        export
            .musicxml
            .contains("<tuplet-type>quarter</tuplet-type>")
    );
    assert!(export.musicxml.contains("<tuplet-normal>"));
    assert!(
        !export
            .musicxml
            .contains("<words>musicxml-tuplet-detail-v1-byes"),
        "tuplet display carrier should not be emitted as words"
    );
    assert!(
        !export
            .diagnostics
            .iter()
            .any(|diagnostic| diagnostic.code == "abc.musicxml.decoration.unsupported"),
        "supported tuplet display carrier should not warn"
    );
}

#[test]
fn musicxml_tuplet_bracket_carrier_emits_bracket_attr() {
    let source = concat!(
        "X:1\n",
        "M:4/4\n",
        "L:1/4\n",
        "K:C\n",
        "(3:2:3!musicxml-tuplet-detail-v1-byes!C D E F|\n",
    );
    let export = export_musicxml(source).expect("tuplet bracket carrier should export");

    assert_balanced_xml(&export.musicxml);
    assert_eq!(
        count(&export.musicxml, "<tuplet type=\"start\" bracket=\"yes\"/>"),
        1
    );
    assert!(!export.musicxml.contains("<tuplet-actual>"));
    assert!(
        !export
            .diagnostics
            .iter()
            .any(|diagnostic| diagnostic.code == "abc.musicxml.decoration.unsupported"),
        "supported tuplet bracket carrier should not warn"
    );
}

#[test]
fn invalid_musicxml_tuplet_display_carrier_is_not_emitted_as_words() {
    let source = concat!(
        "X:1\n",
        "M:4/4\n",
        "L:1/4\n",
        "K:C\n",
        "(4:2:2!musicxml-tuplet-detail-v1-byes-a2-n2-quarter-0!D4 A,4|\n",
    );
    let export = export_musicxml(source).expect("invalid carrier should not break export");

    assert_balanced_xml(&export.musicxml);
    assert!(
        !export.musicxml.contains("<tuplet-actual>"),
        "invalid carrier should not emit tuplet display children"
    );
    assert!(
        !export
            .musicxml
            .contains("<words>musicxml-tuplet-detail-v1-byes"),
        "invalid private carrier should not be emitted as words"
    );
    assert!(
        export
            .diagnostics
            .iter()
            .any(|diagnostic| diagnostic.code == "abc.musicxml.decoration.unsupported"),
        "invalid carrier should fall back to an unsupported-decoration diagnostic"
    );
}

#[test]
fn chord_qualities_map_to_musicxml_kinds_matching_abc2xml() {
    // Each chord symbol must classify to the same <kind> abc2xml emits, so
    // music21 re-renders identical figures from <kind> (it ignores text=).
    let cases = [
        ("F#dim", "diminished"),
        ("Cdim7", "diminished-seventh"),
        ("Caug", "augmented"),
        ("C+", "augmented"),
        ("Co", "diminished"),
        ("C-", "minor"),
        ("Dsus4", "suspended-fourth"),
        ("Dsus2", "suspended-second"),
        ("Csus", "suspended-fourth"),
        ("Cmaj7", "major-seventh"),
        ("CM7", "major-seventh"),
        ("Cm6", "minor-sixth"),
        ("C6", "major-sixth"),
        ("Cm7", "minor-seventh"),
        ("C7", "dominant"),
        ("Cm", "minor"),
        ("C", "major"),
        // Ninth / eleventh / thirteenth kinds.
        ("C9", "dominant-ninth"),
        ("Cmaj9", "major-ninth"),
        ("Cm9", "minor-ninth"),
        ("C11", "dominant-11th"),
        ("Cm11", "minor-11th"),
        ("C13", "dominant-13th"),
        ("Cmaj13", "major-13th"),
        ("Cm13", "minor-13th"),
        // Half-diminished.
        ("Cm7b5", "half-diminished"),
        ("Cmin7b5", "minor-seventh"),
        // Suspended after a seventh keeps only the first kind token.
        ("C7sus4", "dominant"),
        ("Cmaj7sus4", "major-seventh"),
    ];
    for (symbol, expected_kind) in cases {
        let source = format!("X:1\nM:4/4\nL:1/4\nK:C\n\"{symbol}\"C4|\n");
        let export =
            export_musicxml(&source).unwrap_or_else(|_| panic!("chord {symbol} should export"));
        assert_balanced_xml(&export.musicxml);
        let expected = format!("<kind text=\"{symbol}\">{expected_kind}</kind>");
        assert!(
            export.musicxml.contains(&expected),
            "chord {symbol} should map to {expected_kind}; got:\n{}",
            export.musicxml
        );
    }
}

#[test]
fn power_chord_exports_major_kind_with_add_fifth_degree() {
    // abc2xml has no `power` quality: `A5` parses as a major triad with a
    // trailing `5` chord degree, emitted as an added fifth.
    let source = "X:1\nM:4/4\nL:1/4\nK:C\n\"A5\"C4|\n";
    let export = export_musicxml(source).expect("power chord should export");

    assert_balanced_xml(&export.musicxml);
    assert!(export.musicxml.contains("<root-step>A</root-step>"));
    assert!(export.musicxml.contains(">major</kind>"));
    assert!(export.musicxml.contains("<degree-value>5</degree-value>"));
    assert!(export.musicxml.contains("<degree-alter>0</degree-alter>"));
    assert!(export.musicxml.contains("<degree-type>add</degree-type>"));
}

#[test]
fn altered_trailing_degrees_export_as_add_with_alter() {
    // Trailing chord degrees with a `#`/`b` accidental become added degrees
    // with the corresponding alter, matching abc2xml exactly.
    let source = "X:1\nM:4/4\nL:1/4\nK:C\n\"C7b9#5\"C4|\n";
    let export = export_musicxml(source).expect("altered chord should export");

    assert_balanced_xml(&export.musicxml);
    assert!(export.musicxml.contains(">dominant</kind>"));
    assert!(export.musicxml.contains("<degree-value>9</degree-value>"));
    assert!(export.musicxml.contains("<degree-alter>-1</degree-alter>"));
    assert!(export.musicxml.contains("<degree-value>5</degree-value>"));
    assert!(export.musicxml.contains("<degree-alter>1</degree-alter>"));
    assert_eq!(count(&export.musicxml, "<degree>"), 2);
}

#[test]
fn add_and_omit_word_chords_export_as_words_not_harmony() {
    // abc2xml's chord grammar has no `add`/`no` tokens, so these symbols do
    // not parse as harmony at all and fall through to plain text.
    let source = "X:1\nM:4/4\nL:1/4\nK:C\n\"Cadd9\"C \"C9no3\"D \"Cadd11\"E|\n";
    let export = export_musicxml(source).expect("word chords should export");

    assert_balanced_xml(&export.musicxml);
    assert_eq!(count(&export.musicxml, "<harmony>"), 0);
    assert!(export.musicxml.contains("<words>Cadd9</words>"));
    assert!(export.musicxml.contains("<words>C9no3</words>"));
    assert!(export.musicxml.contains("<words>Cadd11</words>"));
}

#[test]
fn double_accidental_and_garbage_roots_are_not_harmony() {
    // abc2xml accepts only a single root accidental and rejects unparsable
    // tails, so these fall through to words rather than fake harmony.
    let source = "X:1\nM:4/4\nL:1/4\nK:C\n\"Cbb\"C \"C##\"D \"Cx\"E \"NC\"F|\n";
    let export = export_musicxml(source).expect("garbage chords should export");

    assert_balanced_xml(&export.musicxml);
    assert_eq!(count(&export.musicxml, "<harmony>"), 0);
    assert!(export.musicxml.contains("<words>Cbb</words>"));
    assert!(export.musicxml.contains("<words>C##</words>"));
    assert!(export.musicxml.contains("<words>Cx</words>"));
    assert!(export.musicxml.contains("<words>NC</words>"));
}

#[test]
fn parenthesized_chord_suffix_is_suppressed() {
    // A trailing parenthesized group is dropped: `C(no3)` is a plain major.
    let source = "X:1\nM:4/4\nL:1/4\nK:C\n\"C(no3)\"C4|\n";
    let export = export_musicxml(source).expect("parenthesized chord should export");

    assert_balanced_xml(&export.musicxml);
    assert_eq!(count(&export.musicxml, "<harmony>"), 1);
    assert!(export.musicxml.contains(">major</kind>"));
    assert_eq!(count(&export.musicxml, "<degree>"), 0);
}

#[test]
fn slash_chord_with_quality_keeps_kind_and_bass() {
    let source = "X:1\nM:4/4\nL:1/4\nK:C\n\"Cm7/Bb\"C4|\n";
    let export = export_musicxml(source).expect("slash chord should export");

    assert_balanced_xml(&export.musicxml);
    assert!(export.musicxml.contains(">minor-seventh</kind>"));
    assert!(export.musicxml.contains("<bass-step>B</bass-step>"));
    assert!(export.musicxml.contains("<bass-alter>-1</bass-alter>"));
}

#[test]
fn tempo_beat_equals_bpm_emits_metronome() {
    let source = "X:1\nM:4/4\nL:1/4\nQ:1/4=104\nK:C\nC4|\n";
    let export = export_musicxml(source).expect("tempo score should export");

    assert_balanced_xml(&export.musicxml);
    assert!(export.musicxml.contains("<metronome>"));
    assert!(export.musicxml.contains("<beat-unit>quarter</beat-unit>"));
    assert!(export.musicxml.contains("<per-minute>104</per-minute>"));
    assert!(export.musicxml.contains("<sound tempo=\"104.00\""));
    assert!(!export.musicxml.contains("<words>1/4=104</words>"));
}

#[test]
fn tempo_dotted_beat_unit_emits_metronome_dot() {
    let source = "X:1\nM:4/4\nL:1/4\nQ:3/8=100\nK:C\nC4|\n";
    let export = export_musicxml(source).expect("tempo score should export");

    assert_balanced_xml(&export.musicxml);
    assert!(export.musicxml.contains("<beat-unit>quarter</beat-unit>"));
    assert!(export.musicxml.contains("<beat-unit-dot"));
    assert!(export.musicxml.contains("<per-minute>100</per-minute>"));
    assert!(export.musicxml.contains("<sound tempo=\"150.00\""));
    assert!(!export.musicxml.contains("<words>3/8=100</words>"));
}

#[test]
fn tempo_bare_number_uses_unit_note_length() {
    let source = "X:1\nM:4/4\nL:1/8\nQ:120\nK:C\nC4|\n";
    let export = export_musicxml(source).expect("tempo score should export");

    assert_balanced_xml(&export.musicxml);
    assert!(export.musicxml.contains("<beat-unit>eighth</beat-unit>"));
    assert!(export.musicxml.contains("<per-minute>120</per-minute>"));
    assert!(export.musicxml.contains("<sound tempo=\"60.00\""));
    assert!(!export.musicxml.contains("<words>120</words>"));
}

#[test]
fn tempo_bare_number_with_trailing_dot_rejected_to_words() {
    // `Q:400.` is NOT a valid bare-number tempo: ABC 2.1 §10.1's deprecated bare
    // form is a bare INTEGER, and a trailing decimal tail is outside the grammar.
    // The strict parser rejects it (keeps the field as verbatim <words>) rather
    // than mining `400` to mimic abc2xml; `croma fmt --auto-fix` rewrites
    // `Q:400.` -> `Q:400` so the strict parser then reads it.
    let source = "X:1\nM:4/4\nL:1/8\nQ:400.\nK:C\nC4|\n";
    let export = export_musicxml(source).expect("tempo score should export");

    assert_balanced_xml(&export.musicxml);
    assert!(export.musicxml.contains("<words>400.</words>"));
    assert_eq!(count(&export.musicxml, "<metronome>"), 0);
    assert!(!export.musicxml.contains("<per-minute>"));
}

#[test]
fn tempo_bare_number_with_legacy_suffix_rejected_to_words() {
    // `Q:320s` (legacy abc2mtex suffix letter) is likewise outside the §10.1
    // bare-integer grammar: the strict parser rejects it to verbatim <words>
    // instead of stripping the `s` to mimic abc2xml; `croma fmt --auto-fix`
    // rewrites `Q:320s` -> `Q:320`.
    let source = "X:1\nM:4/4\nL:1/8\nQ:320s\nK:C\nC4|\n";
    let export = export_musicxml(source).expect("tempo score should export");

    assert_balanced_xml(&export.musicxml);
    assert!(export.musicxml.contains("<words>320s</words>"));
    assert_eq!(count(&export.musicxml, "<metronome>"), 0);
    assert!(!export.musicxml.contains("<per-minute>"));
}

#[test]
fn tempo_bare_leading_digits_with_space_stays_words() {
    // A bare field with internal whitespace after the leading digits is free
    // text, not a bare-number tempo: croma must NOT mine the leading `3` into a
    // metronome. Guards the lenient bare-number parse against over-triggering.
    let source = "X:1\nM:4/4\nL:1/4\nQ:3 dancers\nK:C\nC4|\n";
    let export = export_musicxml(source).expect("tempo score should export");

    assert_balanced_xml(&export.musicxml);
    assert_eq!(count(&export.musicxml, "<metronome>"), 0);
    assert!(!export.musicxml.contains("<per-minute>"));
}

#[test]
fn tempo_text_only_stays_words_without_synthetic_sound() {
    let source = "X:1\nM:4/4\nL:1/4\nQ:\"Slow\"\nK:C\nC4|\n";
    let export = export_musicxml(source).expect("tempo score should export");

    assert_balanced_xml(&export.musicxml);
    assert!(export.musicxml.contains("<words>Slow</words>"));
    assert!(!export.musicxml.contains("<sound tempo=\"120.00\""));
    assert_eq!(count(&export.musicxml, "<metronome>"), 0);
}

#[test]
fn tempo_text_preserves_unknown_backslash_escape() {
    let source = "X:1\nM:4/4\nL:1/4\nQ:\"a\\q\"\nK:C\nC4|\n";
    let export = export_musicxml(source).expect("tempo score should export");

    assert_balanced_xml(&export.musicxml);
    assert!(
        export.musicxml.contains("<words>a\\q</words>"),
        "unknown backslash escapes in Q: text must preserve the literal backslash:\n{}",
        export.musicxml
    );
}

#[test]
fn sound_tempo_carrier_emits_playback_without_metronome() {
    let source = concat!(
        "X:1\n",
        "M:4/4\n",
        "L:1/4\n",
        "K:C\n",
        "[I:croma-sound-tempo bpm=80 beat-n=1 beat-d=4 text=\"80\"]C4|\n",
    );
    let export = export_musicxml(source).expect("sound-tempo carrier should export");

    assert_balanced_xml(&export.musicxml);
    assert!(export.musicxml.contains("<words>80</words>"));
    assert!(export.musicxml.contains("<sound tempo=\"80.00\""));
    assert_eq!(
        count(&export.musicxml, "<metronome>"),
        0,
        "private playback-tempo carrier must not print a metronome:\n{}",
        export.musicxml
    );
}

#[test]
fn empty_tempo_field_emits_no_direction() {
    // An empty `Q:` field carries neither a numeric tempo nor a quoted text
    // string (ABC 2.1 §3.1.8), so there is nothing to render. Croma must not
    // emit a degenerate empty <words></words> direction — abc2xml emits nothing,
    // and croma already suppresses empty "" annotations, so this should match.
    let source = "X:1\nM:4/4\nL:1/4\nQ:\nK:C\nC4|\n";
    let export = export_musicxml(source).expect("empty tempo score should export");

    assert_balanced_xml(&export.musicxml);
    assert_eq!(count(&export.musicxml, "<words>"), 0);
    assert_eq!(count(&export.musicxml, "<direction>"), 0);
    assert_eq!(count(&export.musicxml, "<direction "), 0);
}

#[test]
fn tempo_text_plus_beat_emits_words_and_metronome() {
    let source = "X:1\nM:4/4\nL:1/4\nQ:\"allegretto\" 1/4=110\nK:C\nC4|\n";
    let export = export_musicxml(source).expect("tempo score should export");

    assert_balanced_xml(&export.musicxml);
    assert!(export.musicxml.contains("<words>allegretto</words>"));
    assert!(export.musicxml.contains("<metronome>"));
    assert!(export.musicxml.contains("<beat-unit>quarter</beat-unit>"));
    assert!(export.musicxml.contains("<per-minute>110</per-minute>"));
    assert!(export.musicxml.contains("<sound tempo=\"110.00\""));
}

#[test]
fn later_header_tempo_overrides_earlier_header_tempo() {
    let source = "X:1\nQ:1/4=120\nQ:\"Animato\"\nM:4/4\nL:1/4\nK:C\nC4|\n";
    let export = export_musicxml(source).expect("tempo override score should export");

    assert_balanced_xml(&export.musicxml);
    assert!(export.musicxml.contains("<words>Animato</words>"));
    assert!(!export.musicxml.contains("<sound tempo=\"120.00\""));
    assert_eq!(count(&export.musicxml, "<metronome>"), 0);
    assert!(!export.musicxml.contains("<per-minute>120</per-minute>"));
}

#[test]
fn midi_program_and_channel_become_part_list_instrument_not_words() {
    // `%%MIDI program`/`channel` carry genuine instrument identity (an abc2midi
    // convention, not ABC 2.1 §-text). They map to a `<part-list>`
    // `<score-instrument>`/`<midi-instrument>` (1-based `<midi-program>` = GM+1,
    // `<midi-channel>`), matching abc2xml's part-list emission. They are NEVER
    // rendered as visible staff `<words>`. Other preserved `%%` directives
    // (and the raw `%%MIDI` text) still must not leak. A real `Q:` tempo and
    // the actual notes must survive, proving we translated only the instrument.
    let source = concat!(
        "X:1\n",
        "T:Test\n",
        "M:C\n",
        "L:1/8\n",
        "Q:1/4=104\n",
        "K:G\n",
        "%%MIDI program 72\n",
        "%%MIDI channel 1\n",
        "CDEF GABc |\n",
    );
    let export = export_musicxml(source).expect("score with MIDI directives should export");

    assert_balanced_xml(&export.musicxml);
    // The instrument identity is emitted as part-list metadata.
    assert!(
        export
            .musicxml
            .contains("<instrument-name>piccolo</instrument-name>")
    );
    assert!(export.musicxml.contains("<midi-channel>1</midi-channel>"));
    assert!(export.musicxml.contains("<midi-program>73</midi-program>"));
    // No preserved `%%`-derived directive may leak out as raw text or <words>.
    assert!(!export.musicxml.contains("%%MIDI"));
    assert!(!export.musicxml.contains("%%"));
    assert!(!export.musicxml.contains("<words>"));
    // Real content survives: the `Q:` tempo metronome and the notes.
    assert!(export.musicxml.contains("<metronome>"));
    assert!(export.musicxml.contains("<per-minute>104</per-minute>"));
    assert!(export.musicxml.contains("<step>C</step>"));
    assert!(export.musicxml.contains("<step>G</step>"));
}

#[test]
fn midi_program_header_emits_score_and_midi_instrument() {
    // A single-voice header `%%MIDI program 72` (the dominant corpus placement)
    // attaches to the one part. `<midi-program>` is 1-based (GM+1 = 73) and the
    // instrument name is the General MIDI name for program 72 (piccolo).
    let source = "X:1\nT:T\nL:1/8\n%%MIDI program 72\nK:C\nCDEF GABc |\n";
    let export = export_musicxml(source).expect("program directive should export");

    assert_balanced_xml(&export.musicxml);
    assert!(export.musicxml.contains("<score-instrument"));
    assert!(
        export
            .musicxml
            .contains("<instrument-name>piccolo</instrument-name>")
    );
    assert!(export.musicxml.contains("<midi-instrument"));
    assert!(export.musicxml.contains("<midi-program>73</midi-program>"));
    // No channel was declared, so no <midi-channel> is emitted.
    assert!(!export.musicxml.contains("<midi-channel>"));
    // Instrument identity is part-list metadata, never visible staff text.
    assert!(!export.musicxml.contains("<words>"));
}

#[test]
fn midi_instrument_prefers_voice_instrument_name_metadata() {
    let source =
        "X:1\nT:T\nL:1/8\nV:1 name=\"Staff\" nm=\"Solo Violin\"\n%%MIDI program 40\nK:C\nCDEF|\n";
    let export = export_musicxml(source).expect("named MIDI voice should export");

    assert_balanced_xml(&export.musicxml);
    assert!(export.musicxml.contains("<part-name>Staff</part-name>"));
    assert!(
        export
            .musicxml
            .contains("<instrument-name>Solo Violin</instrument-name>"),
        "voice nm/name should preserve the human instrument name instead of forcing GM fallback:\n{}",
        export.musicxml
    );
}

#[test]
fn midi_instrument_preserves_explicit_empty_voice_instrument_name() {
    let source = "X:1\nT:T\nL:1/8\nV:1 name=\"Staff\" nm=\"\"\n%%MIDI program 40\nK:C\nCDEF|\n";
    let export = export_musicxml(source).expect("empty-named MIDI voice should export");

    assert_balanced_xml(&export.musicxml);
    assert!(
        export
            .musicxml
            .contains("<instrument-name></instrument-name>"),
        "explicit empty voice nm/name should not fall back to the GM program name:\n{}",
        export.musicxml
    );
    assert!(
        !export.musicxml.contains("violin"),
        "explicit empty voice nm/name must be authoritative over GM fallback:\n{}",
        export.musicxml
    );
}

#[test]
fn midi_program_two_integer_form_sets_channel_and_program() {
    // `%%MIDI program <chan> <prog>` (the two-integer corpus form) sets both the
    // MIDI channel and the program. abc2xml: <midi-channel>1 + <midi-program>6
    // (1-based) for `program 1 5`; name from GM program 5 = electric_piano_2.
    let source = "X:1\nT:T\nL:1/8\n%%MIDI program 1 5\nK:C\nCDEF GABc |\n";
    let export = export_musicxml(source).expect("channel-prefixed program should export");

    assert_balanced_xml(&export.musicxml);
    assert!(export.musicxml.contains("<midi-channel>1</midi-channel>"));
    assert!(export.musicxml.contains("<midi-program>6</midi-program>"));
    assert!(
        export
            .musicxml
            .contains("<instrument-name>electric_piano_2</instrument-name>")
    );
}

#[test]
fn midi_program_scopes_to_the_declaring_voice() {
    // Per-voice scoping is load-bearing (ABC 2.1 §4.16 voice fields; abc2midi
    // convention): a `%%MIDI program` attaches to the most recently declared
    // `V:`. Each voice's part gets its own instrument and they must not bleed
    // across parts. program 40 = violin, program 42 = cello.
    let source = concat!(
        "X:1\nT:T\nL:1/8\n",
        "V:1\n%%MIDI program 40\n",
        "V:2\n%%MIDI program 42\n",
        "K:C\n",
        "V:1\nCDEF|\n",
        "V:2\nC,2C,2|\n",
    );
    let export = export_musicxml(source).expect("multi-voice programs should export");

    assert_balanced_xml(&export.musicxml);
    let (before_p2, p2) = export
        .musicxml
        .split_once("<score-part id=\"P2\"")
        .expect("two score-parts");
    // P1's score-part (everything before P2's) carries violin, not cello.
    assert!(before_p2.contains("<instrument-name>violin</instrument-name>"));
    assert!(before_p2.contains("<midi-program>41</midi-program>"));
    assert!(!before_p2.contains("cello"));
    // P2's score-part carries cello.
    assert!(p2.contains("<instrument-name>cello</instrument-name>"));
    assert!(p2.contains("<midi-program>43</midi-program>"));
}

#[test]
fn midi_program_scopes_to_body_declared_voice() {
    // Voice scoping also applies to `V:`/`%%MIDI` pairs introduced in the body
    // (after `K:`), where the voices live in the body field stream rather than
    // the header. program 40 = violin (P1), program 42 = cello (P2).
    let source = concat!(
        "X:1\nT:T\nL:1/8\nK:C\n",
        "V:1\n%%MIDI program 40\nCDEF|\n",
        "V:2\n%%MIDI program 42\nC,2C,2|\n",
    );
    let export = export_musicxml(source).expect("body multi-voice programs should export");

    assert_balanced_xml(&export.musicxml);
    let (before_p2, p2) = export
        .musicxml
        .split_once("<score-part id=\"P2\"")
        .expect("two score-parts");
    assert!(before_p2.contains("<instrument-name>violin</instrument-name>"));
    assert!(!before_p2.contains("cello"));
    assert!(p2.contains("<instrument-name>cello</instrument-name>"));
}

#[test]
fn midi_playback_only_directives_emit_no_instrument() {
    // Only score-meaningful sub-directives translate. Playback-only ones
    // (gchord, nobarlines, ...) stay preserved for round-trip and produce no
    // MusicXML instrument and no leaked text.
    let source = "X:1\nT:T\nL:1/8\n%%MIDI gchord off\n%%MIDI nobarlines\nK:C\nCDEF|\n";
    let export = export_musicxml(source).expect("playback-only directives should export");

    assert_balanced_xml(&export.musicxml);
    assert!(!export.musicxml.contains("<score-instrument"));
    assert!(!export.musicxml.contains("<midi-instrument"));
    assert!(!export.musicxml.contains("gchord"));
    assert!(!export.musicxml.contains("%%MIDI"));
}

#[test]
fn midi_control_cc7_becomes_midi_volume() {
    // `%%MIDI control 7 <n>` is MIDI CC7 (channel volume) → `<midi-instrument>`
    // `<volume>` = n/1.27 (abc2xml parity), even with no program in the voice.
    let source = "X:1\nT:T\nL:1/8\n%%MIDI control 7 120\nK:C\nCDEF|\n";
    let export = export_musicxml(source).expect("control 7 should export");

    assert_balanced_xml(&export.musicxml);
    assert!(export.musicxml.contains("<midi-instrument"));
    assert!(export.musicxml.contains("<volume>94.49</volume>"));
    // No program ⇒ the instrument name falls back to the part name, never the
    // literal "no name" filler abc2xml uses.
    assert!(
        export
            .musicxml
            .contains("<instrument-name>T</instrument-name>")
    );
    assert!(!export.musicxml.contains("no name"));
}

#[test]
fn midi_control_cc10_becomes_midi_pan() {
    // `%%MIDI control 10 <n>` is MIDI CC10 (pan) → `<pan>` = n/127*180-90.
    // n=0 → -90.00 (hard left); n=127 → 90.00 (hard right). 0 is emitted.
    let left = export_musicxml("X:1\nT:T\nL:1/8\n%%MIDI control 10 0\nK:C\nCDEF|\n")
        .expect("control 10 0 should export");
    assert_balanced_xml(&left.musicxml);
    assert!(left.musicxml.contains("<pan>-90.00</pan>"));

    let right = export_musicxml("X:1\nT:T\nL:1/8\n%%MIDI control 10 127\nK:C\nCDEF|\n")
        .expect("control 10 127 should export");
    assert!(right.musicxml.contains("<pan>90.00</pan>"));

    let center = export_musicxml("X:1\nT:T\nL:1/8\n%%MIDI control 10 64\nK:C\nCDEF|\n")
        .expect("control 10 64 should export");
    assert!(
        center.musicxml.contains("<pan>0.00</pan>"),
        "center CC10 should emit exact MusicXML center pan, got:\n{}",
        center.musicxml
    );
}

#[test]
fn midi_other_controllers_are_not_translated() {
    // Only CC7 (volume) and CC10 (pan) carry score-relevant sound metadata;
    // every other controller is playback-only and produces no instrument.
    let source = "X:1\nT:T\nL:1/8\n%%MIDI control 2 70\nK:C\nCDEF|\n";
    let export = export_musicxml(source).expect("other controllers should export");

    assert_balanced_xml(&export.musicxml);
    assert!(!export.musicxml.contains("<volume>"));
    assert!(!export.musicxml.contains("<pan>"));
    assert!(!export.musicxml.contains("<midi-instrument"));
}

#[test]
fn midi_channel_only_emits_instrument_with_part_name() {
    // `%%MIDI channel <n>` with NO program still carries sound metadata (the
    // channel). It emits a <midi-channel>, with the instrument name falling back
    // to the part name (never abc2xml's "no name").
    let source = "X:1\nT:Tune\nL:1/8\n%%MIDI channel 3\nK:C\nCDEF|\n";
    let export = export_musicxml(source).expect("channel-only should export");

    assert_balanced_xml(&export.musicxml);
    assert!(export.musicxml.contains("<midi-channel>3</midi-channel>"));
    assert!(
        export
            .musicxml
            .contains("<instrument-name>Tune</instrument-name>")
    );
    assert!(!export.musicxml.contains("no name"));
    // No program declared ⇒ no <midi-program>.
    assert!(!export.musicxml.contains("<midi-program>"));
}

#[test]
fn midi_inline_program_field_emits_instrument() {
    // The inline `[I: MIDI=program N]` form (mid-music-line) carries the same
    // instrument identity as the line-start directive and is translated, not
    // dropped. program 42 = cello → <midi-program>43.
    let source = "X:1\nT:T\nL:1/8\nK:C\n[I: MIDI=program 42]CDEF GABc |\n";
    let export = export_musicxml(source).expect("inline program should export");

    assert_balanced_xml(&export.musicxml);
    assert!(
        export
            .musicxml
            .contains("<instrument-name>cello</instrument-name>")
    );
    assert!(export.musicxml.contains("<midi-program>43</midi-program>"));
}

#[test]
fn midi_inline_program_scopes_per_voice() {
    // Each voice's inline `[I: MIDI=program N]` lands in its own part (the
    // tune_007210 family pattern). program 40 = violin (P1), 42 = cello (P2).
    let source = concat!(
        "X:1\nT:T\nL:1/8\n",
        "V:1\nV:2\nK:C\n",
        "V:1\n[I: MIDI=program 40]CDEF|\n",
        "V:2\n[I: MIDI=program 42]C,2C,2|\n",
    );
    let export = export_musicxml(source).expect("inline multi-voice programs should export");

    assert_balanced_xml(&export.musicxml);
    let (before_p2, p2) = export
        .musicxml
        .split_once("<score-part id=\"P2\"")
        .expect("two score-parts");
    assert!(before_p2.contains("<instrument-name>violin</instrument-name>"));
    assert!(!before_p2.contains("cello"));
    assert!(p2.contains("<instrument-name>cello</instrument-name>"));
}

#[test]
fn non_midi_inline_instruction_emits_no_instrument() {
    // A non-MIDI inline `[I:...]` instruction must not be mistaken for a MIDI
    // directive and must produce no instrument.
    let source = "X:1\nT:T\nL:1/8\nK:C\n[I:staffwidth 100]CDEF|\n";
    let export = export_musicxml(source).expect("non-MIDI inline should export");

    assert_balanced_xml(&export.musicxml);
    assert!(!export.musicxml.contains("<score-instrument"));
    assert!(!export.musicxml.contains("<midi-instrument"));
}

#[test]
fn croma_note_instrument_carrier_emits_note_instrument_ids() {
    let source = concat!(
        "X:1\n",
        "T:Perc\n",
        "L:1/4\n",
        "K:C\n",
        "%%croma-musicxml-instrument id=\"P1-I1\" name=\"Snare Drum\" channel=10 midi-unpitched=39\n",
        "%%croma-musicxml-instrument id=\"P1-I2\" name=\"Wood Block\" channel=10 midi-unpitched=76\n",
        "[I:croma-note-instrument id=\"P1-I1\"]D [I:croma-note-instrument id=\"P1-I2\"]C |\n",
    );
    let export = export_musicxml(source).expect("croma note instrument carrier should export");

    assert_balanced_xml(&export.musicxml);
    assert!(
        export.musicxml.contains("<score-instrument id=\"P1-I1\">")
            && export
                .musicxml
                .contains("<instrument-name>Snare Drum</instrument-name>")
            && export
                .musicxml
                .contains("<midi-unpitched>39</midi-unpitched>")
            && export.musicxml.contains("<score-instrument id=\"P1-I2\">")
            && export
                .musicxml
                .contains("<instrument-name>Wood Block</instrument-name>")
            && export
                .musicxml
                .contains("<midi-unpitched>76</midi-unpitched>"),
        "part-list instrument metadata must come from croma carriers:\n{}",
        export.musicxml
    );
    assert!(
        export.musicxml.contains("<instrument id=\"P1-I1\"/>")
            && export.musicxml.contains("<instrument id=\"P1-I2\"/>"),
        "note-level instrument refs must be emitted before <voice>:\n{}",
        export.musicxml
    );
}

#[test]
fn croma_instrument_carrier_preserves_explicit_empty_name() {
    let source = concat!(
        "X:1\n",
        "T:Empty Name\n",
        "L:1/4\n",
        "K:C\n",
        "%%croma-musicxml-instrument id=\"P1-I1\" name=\"\" channel=1 program=0 volume-cc=100 pan-cc=64\n",
        "[I:croma-note-instrument id=\"P1-I1\"]C |\n",
    );
    let export =
        export_musicxml(source).expect("empty-name croma instrument carrier should export");

    assert_balanced_xml(&export.musicxml);
    assert!(
        export
            .musicxml
            .contains("<instrument-name></instrument-name>"),
        "explicit empty instrument name must remain empty:\n{}",
        export.musicxml
    );
    assert!(
        export.musicxml.contains("<midi-channel>1</midi-channel>")
            && export.musicxml.contains("<midi-program>1</midi-program>")
            && export.musicxml.contains("<volume>78.74</volume>")
            && export.musicxml.contains("<pan>0.00</pan>"),
        "empty-name croma carrier must preserve the sibling MIDI fields:\n{}",
        export.musicxml
    );
    assert!(
        !export.musicxml.contains("acoustic_grand_piano"),
        "empty croma instrument name must not fall back to the GM program name:\n{}",
        export.musicxml
    );
}

#[test]
fn midi_transpose_emits_transpose_attribute_without_shifting_pitch() {
    // `%%MIDI transpose <n>` is an abc2midi playback transpose that abc2xml maps
    // to MusicXML `<attributes><transpose><chromatic>n` (written-vs-sounding
    // pitch). It must NOT shift the written notes — only declare the transpose.
    let source = "X:1\nT:T\nL:1/8\n%%MIDI transpose -12\nK:C\nCDEF GABc |\n";
    let export = export_musicxml(source).expect("transpose directive should export");

    assert_balanced_xml(&export.musicxml);
    assert!(export.musicxml.contains("<transpose>"));
    assert!(export.musicxml.contains("<chromatic>-12</chromatic>"));
    // Written pitch is unchanged: the first note is still C (no octave shift).
    let first_step = export
        .musicxml
        .split("<step>")
        .nth(1)
        .and_then(|tail| tail.split("</step>").next());
    assert_eq!(first_step, Some("C"));
    // It is not visible staff text.
    assert!(!export.musicxml.contains("<words>"));
}

#[test]
fn midi_transpose_scopes_to_the_declaring_voice() {
    // Per-voice scoping: each voice's `%%MIDI transpose` lands in its own part's
    // first-measure <attributes>.
    let source = concat!(
        "X:1\nT:T\nL:1/8\n",
        "V:1\n%%MIDI transpose -12\n",
        "V:2\n%%MIDI transpose -24\n",
        "K:C\n",
        "V:1\nCDEF|\n",
        "V:2\nC,2C,2|\n",
    );
    let export = export_musicxml(source).expect("multi-voice transpose should export");

    assert_balanced_xml(&export.musicxml);
    let (p1, p2) = export
        .musicxml
        .split_once("<part id=\"P2\"")
        .expect("two parts");
    assert!(p1.contains("<chromatic>-12</chromatic>"));
    assert!(!p1.contains("<chromatic>-24"));
    assert!(p2.contains("<chromatic>-24</chromatic>"));
}

#[test]
fn midi_transpose_coexists_with_program_in_one_voice() {
    // A voice with both a program and a transpose emits the part-list instrument
    // (program) AND the <attributes> transpose.
    let source = "X:1\nT:T\nL:1/8\n%%MIDI program 40\n%%MIDI transpose -12\nK:C\nCDEF|\n";
    let export = export_musicxml(source).expect("program + transpose should export");

    assert_balanced_xml(&export.musicxml);
    assert!(export.musicxml.contains("<midi-program>41</midi-program>"));
    assert!(export.musicxml.contains("<chromatic>-12</chromatic>"));
}

#[test]
fn placement_prefixed_annotations_remain_words() {
    let source = "X:1\nM:4/4\nL:1/4\nK:C\n\"^slow\"C \"_soft\"D|\n";
    let export = export_musicxml(source).expect("annotations should export");

    assert_balanced_xml(&export.musicxml);
    assert_eq!(count(&export.musicxml, "<harmony>"), 0);
    assert!(export.musicxml.contains("<direction placement=\"above\">"));
    assert!(export.musicxml.contains("<direction placement=\"below\">"));
    assert!(export.musicxml.contains("<words>slow</words>"));
    assert!(export.musicxml.contains("<words>soft</words>"));
}

#[test]
fn initial_barlines_do_not_emit_musicxml_heavy_light() {
    let source = "X:1\nT:Initial Barline\nM:4/4\nL:1/4\nK:C\nC |[| D |]\n";
    let export = export_musicxml(source).expect("initial barline should export");

    assert_balanced_xml(&export.musicxml);
    assert!(
        !export
            .musicxml
            .contains("<bar-style>heavy-light</bar-style>")
    );
    assert!(
        export
            .musicxml
            .contains("<bar-style>light-heavy</bar-style>")
    );
}

#[test]
fn leading_plain_barline_exports_notes_in_measure_one() {
    let source = "X:1\nM:4/4\nL:1/4\nK:C\n| C D E F |]\n";
    let export = export_musicxml(source).expect("leading barline should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(measure_numbers(&measures), vec!["1"]);
    assert_eq!(note_steps(&measures[0]), vec!['C', 'D', 'E', 'F']);
    assert_eq!(note_durations(&measures[0]), vec![8, 8, 8, 8]);
    assert!(!measures[0].notes.iter().any(|note| note.rest));
}

#[test]
fn leading_repeat_start_exports_left_repeat_without_empty_measure() {
    let source = "X:1\nM:4/4\nL:1/4\nK:C\n|: C D E F :|\n";
    let export = export_musicxml(source).expect("leading repeat should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(measure_numbers(&measures), vec!["1"]);
    assert_eq!(note_steps(&measures[0]), vec!['C', 'D', 'E', 'F']);
    assert_eq!(note_durations(&measures[0]), vec![8, 8, 8, 8]);
    assert!(has_barline(
        &measures[0],
        "left",
        Some("heavy-light"),
        Some("forward")
    ));
    assert!(has_barline(
        &measures[0],
        "right",
        Some("light-heavy"),
        Some("backward")
    ));
}

#[test]
fn repeat_barlines_emit_explicit_musicxml_bar_styles() {
    let source = "X:1\nM:4/4\nL:1/4\nK:C\n|: C D E F :||: G A B c :|\n";
    let export = export_musicxml(source).expect("repeat barlines should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(measure_numbers(&measures), vec!["1", "2"]);
    assert!(has_barline(
        &measures[0],
        "left",
        Some("heavy-light"),
        Some("forward")
    ));
    assert!(has_barline(
        &measures[0],
        "right",
        Some("light-heavy"),
        Some("backward")
    ));
    assert!(has_barline(
        &measures[1],
        "left",
        Some("heavy-light"),
        Some("forward")
    ));
    assert!(has_barline(
        &measures[1],
        "right",
        Some("light-heavy"),
        Some("backward")
    ));
}

#[test]
fn repeat_ending_closes_before_trailing_repeat_start() {
    let source = "X:1\nM:2/4\nL:1/4\nK:C\n|: C D |1 E F :|2 G A |: B c |]\n";
    let export = export_musicxml(source).expect("repeat ending should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(measure_numbers(&measures), vec!["1", "2", "3", "4"]);
    assert!(has_ending(&measures[2], "right", "2", "stop"));
    assert!(has_barline(
        &measures[3],
        "left",
        Some("heavy-light"),
        Some("forward")
    ));
    assert!(!has_ending(&measures[3], "right", "2", "stop"));
}

#[test]
fn repeat_ending_closes_before_next_leading_repeat_start() {
    let source = "X:1\nM:2/4\nL:1/4\nK:C\n|: C D |1 E F :|2 G A |\n|: B c |]\n";
    let export = export_musicxml(source).expect("repeat ending should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(measure_numbers(&measures), vec!["1", "2", "3", "4"]);
    assert!(has_ending(&measures[2], "right", "2", "stop"));
    assert!(has_barline(
        &measures[3],
        "left",
        Some("heavy-light"),
        Some("forward")
    ));
    assert!(!has_ending(&measures[3], "right", "2", "stop"));
}

#[test]
fn mid_measure_repeat_ending_starts_a_pickup_measure() {
    let source = "X:1\nM:4/4\nL:1/8\nK:C\n|: A2 A2 A2[1 B2 | C8 :|\n[2 D2 | E8 |]\n";
    let export = export_musicxml(source).expect("mid-measure repeat ending should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(measure_numbers(&measures), vec!["1", "2", "3", "4", "5"]);
    assert_eq!(note_steps(&measures[0]), vec!['A', 'A', 'A']);
    assert_eq!(note_steps(&measures[1]), vec!['B']);
    assert!(has_ending(&measures[1], "left", "1", "start"));
    assert!(!has_ending(&measures[0], "left", "1", "start"));
    assert!(has_ending(&measures[2], "right", "1", "stop"));
    assert_eq!(note_steps(&measures[3]), vec!['D']);
    assert!(has_ending(&measures[3], "left", "2", "start"));
    assert!(has_ending(&measures[4], "right", "2", "stop"));
}

#[test]
fn leading_double_and_final_barlines_do_not_create_empty_measure() {
    for prefix in ["||", "|]"] {
        let source = format!("X:1\nM:4/4\nL:1/4\nK:C\n{prefix} C D E F |]\n");
        let export = export_musicxml(&source).expect("leading section barline should export");

        assert_balanced_xml(&export.musicxml);
        let measures = musicxml_measures(&export.musicxml);
        assert_eq!(measure_numbers(&measures), vec!["1"], "{prefix}");
        assert_eq!(note_steps(&measures[0]), vec!['C', 'D', 'E', 'F']);
        assert_eq!(note_durations(&measures[0]), vec![8, 8, 8, 8]);
        assert!(
            !measures[0]
                .barlines
                .iter()
                .any(|barline| barline.location == "left")
        );
        assert!(has_barline(
            &measures[0],
            "right",
            Some("light-heavy"),
            None
        ));
    }
}

#[test]
fn double_bar_across_backslash_continuation_is_coalesced() {
    // tune_001312: a music line ending `...|\` continued (backslash) to a line
    // starting with `|` forms an adjacent `||` thin-thin double bar (the line
    // continuation joins them for processing). croma dropped it — measure 1 (the
    // bar before the seam) got no right barline. abc2xml emits light-light.
    let source = "X:1\nM:4/4\nL:1/4\nK:C\nC D E F |\\\n| G A B c|]\n";
    let export = export_musicxml(source).expect("seam || should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert!(
        has_barline(&measures[0], "right", Some("light-light"), None),
        "the `|\\`+`|` seam must coalesce into a light-light double bar: {}",
        export.musicxml
    );
}

#[test]
fn double_bar_across_backslash_continuation_before_variant_ending_is_coalesced() {
    // tune_006302: a music line ending `...|\` continued to a line starting `|1`
    // forms `||1` after the §6.1.1 join — an adjacent `||` thin-thin double bar
    // (light-light) on the measure before the seam, with the variant ending then
    // starting on the NEXT measure's left. croma's coalesce skipped this because
    // the next line opens a variant ending, dropping the double bar (the pre-seam
    // measure got no right barline). ABC 2.1 §6.1.1 (line continuation) + §4.8.
    let source = "X:1\nM:4/4\nL:1/4\nK:C\nC D E F |\\\n|1 G A B c :|2 d e f g |]\n";
    let export = export_musicxml(source).expect("seam ||1 should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert!(
        has_barline(&measures[0], "right", Some("light-light"), None),
        "the `|\\`+`|1` seam must coalesce into a light-light double bar: {}",
        export.musicxml
    );
    assert!(
        has_ending(&measures[1], "left", "1", "start"),
        "ending 1 must start on the measure after the double bar, not fold in: {}",
        export.musicxml
    );
}

#[test]
fn backslash_seam_across_intervening_field_does_not_coalesce() {
    // tune_013361: a `\` continuation survives an intervening information field
    // (`...| \` / `M:2/4` / `|1 ...`), but that field is a boundary between the
    // two bars, so they are NOT adjacent and must not merge into `||`. The first
    // measure keeps its plain right bar and ending 1 starts on the next measure.
    let source = "X:1\nL:1/4\nK:C\nC D E F | \\\nM:2/4\n|1 G A :|2 c d |]\n";
    let export = export_musicxml(source).expect("field-seam should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert!(
        !has_barline(&measures[0], "right", Some("light-light"), None),
        "a `\\` seam across an intervening field must NOT coalesce into `||`: {}",
        export.musicxml
    );
    assert!(
        has_ending(&measures[1], "left", "1", "start"),
        "ending 1 still starts on the measure after the field: {}",
        export.musicxml
    );
}

#[test]
fn forward_repeat_and_second_ending_share_one_left_barline() {
    // tune_005957: `:|:[2` puts a forward-repeat start AND a 2nd-ending start on
    // one measure's left edge. croma emitted TWO <barline location="left">, and
    // music21 keeps only the second, silently dropping the forward repeat (Bug 8).
    let source = "X:1\nM:4/4\nL:1/4\nK:C\n|: C D E F |1 G A B c :|:[2 A B c d|]\n";
    let export = export_musicxml(source).expect(":|:[2 should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    let last = measures.last().expect("second-ending measure");
    let lefts: Vec<_> = last
        .barlines
        .iter()
        .filter(|barline| barline.location == "left")
        .collect();
    assert_eq!(
        lefts.len(),
        1,
        "the 2nd-ending measure must carry ONE merged left barline, not two: {}",
        export.musicxml
    );
    assert_eq!(
        lefts[0].repeat_direction.as_deref(),
        Some("forward"),
        "the forward repeat must survive on the merged left barline: {}",
        export.musicxml
    );
}

#[test]
fn liberal_colon_thick_barline_is_a_backward_repeat() {
    // ABC 2.1 §4.8 liberal recognition: `:]` = `:|]` (repeat-end dots + thick
    // final bar), so the closing bar is light-heavy + a backward repeat. croma
    // normalized `:]` to a bare boundary, dropping both (tune_000746).
    let source = "X:1\nM:4/4\nL:1/4\nK:C\n|: C D E F | G A B c :]\n";
    let export = export_musicxml(source).expect(":] should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    let last = measures.last().expect("at least one measure");
    assert!(
        has_barline(last, "right", Some("light-heavy"), Some("backward")),
        "`:]` must export light-heavy + backward repeat: {}",
        export.musicxml
    );
}

#[test]
fn bare_colon_before_variant_ending_is_a_backward_repeat() {
    // tune_013149: `:[2` is a repeat-end whose bar `|` was dropped (§4.9's `:|2`
    // shorthand family) — the `:` closes ending 1 and repeats backward, then `[2`
    // opens ending 2. croma routed the bare `:` to a forward RepeatStart, yielding
    // a structurally impossible all-forward / no-backward tune. ABC 2.1 §4.8 / §4.9
    // (raw line 1009/1021).
    let source = "X:1\nM:4/4\nL:1/4\nK:C\n|: C D E F |[1 G A B c :[2 d e f g |]\n";
    let export = export_musicxml(source).expect("`:[2` should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    let ending1 = &measures[1];
    assert!(
        has_barline(ending1, "right", Some("light-heavy"), Some("backward")),
        "the `:` before `[2` must close ending 1 with a backward repeat: {}",
        export.musicxml
    );
    assert!(
        has_ending(ending1, "right", "1", "stop"),
        "ending 1 must stop at the `:` repeat-end: {}",
        export.musicxml
    );
    assert!(
        has_ending(&measures[2], "left", "2", "start"),
        "ending 2 must start after the repeat-end: {}",
        export.musicxml
    );
}

#[test]
fn fused_final_then_repeat_start_emits_closer_and_left_repeat() {
    // tune_009754: `|]:` is a thin-thick final bar fused with a forward-repeat
    // start (the section transition `... G2 G2 |]:[K:..]"^Dans" ...`). The `|]`
    // closes the current measure (light-heavy) and the trailing `:` opens the
    // next section's repeat (heavy-light + forward). croma merged the run into a
    // bare RepeatStart, dropping the `|]` closer entirely (ABC 2.1 §4.8 line
    // 984 / 1001).
    let source = "X:1\nM:4/4\nL:1/4\nK:C\nC D E F |]: G A B c :|\n";
    let export = export_musicxml(source).expect("|]: should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(measure_numbers(&measures), vec!["1", "2"]);
    assert!(
        has_barline(&measures[0], "right", Some("light-heavy"), None),
        "the `|]` closer must land on measure 1's right with no repeat: {}",
        export.musicxml
    );
    assert!(
        has_barline(&measures[1], "left", Some("heavy-light"), Some("forward")),
        "the `:` forward repeat must lead measure 2: {}",
        export.musicxml
    );
}

#[test]
fn fused_thick_bar_repeat_start_closes_ending_and_starts_repeat() {
    // tune_014316: a 2nd ending closed by `]` abutting a repeat-start `||:`
    // (logical run `]||:`). The `]` closes ending 2 (light-heavy + stop) and the
    // `||:` opens the next repeated section (heavy-light + forward). croma
    // mis-tokenized the whole run as one RepeatStart, dropping the `]` closer
    // and leaving the ending unterminated (ABC 2.1 §4.8 / §4.10).
    let source = "X:1\nM:3/4\nL:1/4\nK:C\nG A B |1 c d e :|[2 f g a ]||: C2 D | E3 :|\n";
    let export = export_musicxml(source).expect("]||: should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    let ending2 = &measures[2];
    assert_eq!(note_steps(ending2), vec!['F', 'G', 'A']);
    assert!(
        has_barline(ending2, "right", Some("light-heavy"), None),
        "the `]` must close ending 2 with light-heavy and no repeat: {}",
        export.musicxml
    );
    assert!(
        has_ending(ending2, "right", "2", "stop"),
        "ending 2 must stop at the `]` closer: {}",
        export.musicxml
    );
    let repeated = &measures[3];
    assert!(
        has_barline(repeated, "left", Some("heavy-light"), Some("forward")),
        "the `||:` forward repeat must lead the next section: {}",
        export.musicxml
    );
}

#[test]
fn leading_liberal_barline_diagnoses_and_keeps_measure_timing() {
    let source = "X:1\nM:4/4\nL:1/4\nK:C\n[::] C D E F |]\n";
    let export = export_musicxml(source).expect("liberal leading barline should recover");

    assert_balanced_xml(&export.musicxml);
    assert_diagnostic_span(
        source,
        &export.diagnostics,
        "abc.music.barline.liberal",
        "[::]",
    );
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(measure_numbers(&measures), vec!["1"]);
    assert_eq!(note_steps(&measures[0]), vec!['C', 'D', 'E', 'F']);
    assert_eq!(note_durations(&measures[0]), vec![8, 8, 8, 8]);
    assert!(!measures[0].notes.iter().any(|note| note.rest));
}

#[test]
fn leading_double_repeat_start_exports_left_repeat_without_empty_measure() {
    let source = "X:1\nM:4/4\nL:1/4\nK:C\n||: C D E F :||\n";
    let export = export_musicxml(source).expect("combined leading repeat should export");

    assert_balanced_xml(&export.musicxml);
    assert!(
        !export
            .diagnostics
            .iter()
            .any(|diagnostic| diagnostic.code == "abc.music.barline.liberal")
    );
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(measure_numbers(&measures), vec!["1"]);
    assert_eq!(note_steps(&measures[0]), vec!['C', 'D', 'E', 'F']);
    assert!(has_barline(
        &measures[0],
        "left",
        Some("heavy-light"),
        Some("forward")
    ));
    assert!(has_barline(
        &measures[0],
        "right",
        Some("light-heavy"),
        Some("backward")
    ));
}

#[test]
fn repeat_end_double_after_notes_exports_right_repeat_and_new_measure() {
    let source = "X:1\nM:4/4\nL:1/4\nK:C\nC D E F :|| G A B c |]\n";
    let export = export_musicxml(source).expect("combined repeat end should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(measure_numbers(&measures), vec!["1", "2"]);
    assert_eq!(note_steps(&measures[0]), vec!['C', 'D', 'E', 'F']);
    assert_eq!(note_steps(&measures[1]), vec!['G', 'A', 'B', 'C']);
    assert!(has_barline(
        &measures[0],
        "right",
        Some("light-heavy"),
        Some("backward")
    ));
}

#[test]
fn thick_thin_seam_barline_keeps_following_repeat_leading() {
    // tune_007014: a measure closing `]|` (thick then thin) followed by a `|:`
    // opening the next section. `]|` was tokenized as `]` (final) PLUS a stray `|`
    // (regular); that stray bar consumed the next measure's leading slot, so the
    // `|:` became non-leading and its forward repeat was deferred off the end
    // (dropped). `]|` is one boundary, and the `|:` must lead the next measure.
    let source = "X:1\nM:4/4\nL:1/4\nK:C\nC D E F ]|\n|: G A B c :|\n";
    let export = export_musicxml(source).expect("`]|` seam should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(measure_numbers(&measures), vec!["1", "2"]);
    assert!(
        has_barline(&measures[0], "right", Some("light-heavy"), None),
        "m1 must close with the `]|` boundary (no stray extra bar): {}",
        export.musicxml
    );
    assert!(
        has_barline(&measures[1], "left", Some("heavy-light"), Some("forward")),
        "the `|:` must lead measure 2 with a forward repeat: {}",
        export.musicxml
    );
}

#[test]
fn spaced_bar_seam_before_repeat_keeps_following_repeat_leading() {
    // tune_010091: a spaced bar-run seam — a whitespace-separated `| |`, or a
    // loose `: |` (colon glued to the note, then a spaced bar) — immediately
    // before a `|:` section open. The second, redundant bar of the spaced run
    // was taking the next measure's leading slot, so the `|:` became non-leading
    // and its forward repeat was deferred one measure too late. Contiguous `||`
    // / `:|` are single tokens and already behave; the spaced forms must match.
    for seam in ["| |", ": |"] {
        let source = format!("X:1\nM:6/8\nL:1/8\nK:A\nGAB cde|fga ab2{seam}\n|:a|aga abc|]\n");
        let export = export_musicxml(&source).expect("spaced seam should export");

        assert_balanced_xml(&export.musicxml);
        let measures = musicxml_measures(&export.musicxml);
        // m3 is the `a` pickup that opens the repeated section.
        assert_eq!(note_steps(&measures[2]), vec!['A'], "seam={seam}");
        assert!(
            has_barline(&measures[2], "left", Some("heavy-light"), Some("forward")),
            "[{seam}] `|:` forward repeat must lead the pickup measure m3, not be \
             deferred by the redundant spaced bar: {}",
            export.musicxml
        );
    }
}

#[test]
fn repeat_both_between_sections_exports_right_then_left_repeat() {
    let source = "X:1\nM:4/4\nL:1/4\nK:C\nC D E F :||: G A B c |]\n";
    let export = export_musicxml(source).expect("repeat-both barline should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(measure_numbers(&measures), vec!["1", "2"]);
    assert!(has_barline(
        &measures[0],
        "right",
        Some("light-heavy"),
        Some("backward")
    ));
    assert!(has_barline(
        &measures[1],
        "left",
        Some("heavy-light"),
        Some("forward")
    ));
    assert_eq!(note_steps(&measures[1]), vec!['G', 'A', 'B', 'C']);
}

#[test]
fn triple_repeat_extensions_export_repeat_edges() {
    let source = "X:1\nM:4/4\nL:1/4\nK:C\n|:: C D E F ::|\n";
    let export = export_musicxml(source).expect("triple repeat barlines should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(measure_numbers(&measures), vec!["1"]);
    assert!(has_barline(
        &measures[0],
        "left",
        Some("heavy-light"),
        Some("forward")
    ));
    assert!(has_barline(
        &measures[0],
        "right",
        Some("light-heavy"),
        Some("backward")
    ));
    assert!(
        measures[0]
            .barlines
            .iter()
            .all(|barline| barline.repeat_times.is_none())
    );
    assert_eq!(note_steps(&measures[0]), vec!['C', 'D', 'E', 'F']);
}

#[test]
fn split_final_after_repeat_end_does_not_duplicate_right_barline() {
    let source = "X:1\nM:4/4\nL:1/4\nK:C\nC D E F:[|]|]\n";
    let export = export_musicxml(source).expect("split final after repeat end should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(measure_numbers(&measures), vec!["1"]);
    let right_barlines = measures[0]
        .barlines
        .iter()
        .filter(|barline| barline.location == "right")
        .collect::<Vec<_>>();
    assert_eq!(
        right_barlines.len(),
        1,
        "right barlines should be merged: {right_barlines:?}"
    );
    assert!(has_barline(
        &measures[0],
        "right",
        Some("light-heavy"),
        Some("backward")
    ));
}

#[test]
fn repeat_end_after_invisible_barline_closes_previous_measure() {
    let source = "X:1\nM:4/4\nL:1/4\nK:C\nC D E F H[|]:|\n|:G A B c:|\n";
    let export = export_musicxml(source).expect("invisible plus repeat end should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(measure_numbers(&measures), vec!["1", "2"]);
    assert!(has_barline(&measures[0], "right", Some("none"), None));
    assert!(has_barline(
        &measures[0],
        "right",
        Some("light-heavy"),
        Some("backward")
    ));
    assert!(has_barline(
        &measures[1],
        "left",
        Some("heavy-light"),
        Some("forward")
    ));
}

#[test]
fn leading_repeat_end_after_plain_bar_seam_closes_previous_measure() {
    // tune_000205: a measure closes with a plain `|` at a line break, then the
    // next line LEADS with `:|` (end-of-repeat). The `:|` opens an empty seam
    // measure, so it is a *leading* RepeatEnd that matches neither barline filter
    // and is dropped. Its backward repeat must instead retro-close the PREVIOUS
    // real measure (like the `[|]:|` invisible case above, but after a plain bar).
    let source = "X:1\nM:4/4\nL:1/4\nK:C\n|:C D E F|\n:|G A B c|]\n";
    let export = export_musicxml(source).expect("leading repeat-end seam should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(measure_numbers(&measures), vec!["1", "2"]);
    assert!(has_barline(
        &measures[0],
        "left",
        Some("heavy-light"),
        Some("forward")
    ));
    assert!(
        has_barline(&measures[0], "right", Some("light-heavy"), Some("backward")),
        "leading `:|` after a plain-bar seam must retro-close m1 with a backward repeat: {}",
        export.musicxml
    );
    assert_eq!(note_steps(&measures[1]), vec!['G', 'A', 'B', 'C']);
}

#[test]
fn leading_repeat_end_after_key_change_seam_closes_previous_measure() {
    // tune_011411: a measure closes `|` at a line break, then a note-less key
    // change (`K:G` on its own line) is the seam measure, then the next line
    // LEADS with `:|`. A key-change-only measure is still "empty" (no timed
    // notes), so the leading `:|`'s backward repeat must retro-close the PREVIOUS
    // real measure — not be dropped on the seam (which left tune_011411's 2nd
    // ending unterminated, leaking m34->m43).
    let source = "X:1\nM:4/4\nL:1/4\nK:C\n|:C D E F|\nK:G\n:|G A B c|]\n";
    let export =
        export_musicxml(source).expect("leading repeat-end after key-change seam should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(measure_numbers(&measures), vec!["1", "2"]);
    assert!(
        has_barline(&measures[0], "right", Some("light-heavy"), Some("backward")),
        "leading `:|` after a key-change seam must retro-close m1 with a backward repeat: {}",
        export.musicxml
    );
    assert_eq!(note_steps(&measures[1]), vec!['G', 'A', 'B', 'C']);
}

#[test]
fn direction_only_measure_preserves_trailing_invisible_barline() {
    let source = "X:1\nK:C\nC|\"^VAR:\"[|]\\\n\"a.\"A [|] y8\n";
    let export = export_musicxml(source).expect("direction-only invisible barline should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(measure_numbers(&measures), vec!["1", "2", "3", "4"]);
    assert!(has_barline(&measures[1], "right", Some("none"), None));
    assert!(has_barline(&measures[2], "right", Some("none"), None));
}

#[test]
fn direction_only_measure_preserves_trailing_initial_barline() {
    let source = "X:1\nK:C\nC|\"^B\"[|A|]\n";
    let export = export_musicxml(source).expect("direction-only initial barline should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(measure_numbers(&measures), vec!["1", "2", "3"]);
    assert!(has_barline(
        &measures[1],
        "right",
        Some("heavy-light"),
        None
    ));
    assert!(has_barline(
        &measures[2],
        "right",
        Some("light-heavy"),
        None
    ));
}

#[test]
fn leading_invisible_barline_on_annotation_only_measure_is_emitted() {
    // tune_004088/010209: `"^A"[|] CDEF` at body start. The invisible barline
    // `[|]` terminates a note-less annotation-only measure 1 and must export as
    // <bar-style>none</bar-style>; croma logged the policy but dropped the glyph
    // (the barline leads its measure and matched neither barline filter).
    let source = "X:1\nM:4/4\nL:1/4\nK:C\n\"^A\"[|] C D E F|]\n";
    let export = export_musicxml(source).expect("leading invisible barline should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert!(
        has_barline(&measures[0], "right", Some("none"), None),
        "annotation-only measure 1 must keep its [|] none barline: {}",
        export.musicxml
    );
}

#[test]
fn closing_barline_on_directive_and_spacer_only_measure_is_emitted() {
    // tune_003124: `...!segno!y |]` — a measure whose only content is a !segno!
    // directive plus a spacer `y` (no note) must still emit its `|]` light-heavy.
    let source = "X:1\nM:4/4\nL:1/4\nK:C\nC D E F|!segno!y |]\n";
    let export = export_musicxml(source).expect("directive+spacer barline should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert!(
        has_barline(
            measures.last().expect("at least one measure"),
            "right",
            Some("light-heavy"),
            None
        ),
        "directive+spacer-only measure must keep its |] barline: {}",
        export.musicxml
    );
}

#[test]
fn empty_slur_pair_does_not_emit_degenerate_musicxml_slur() {
    let source = "X:1\nM:4/4\nL:1/4\nK:C\nC() D E F|]\n";
    let export = export_musicxml(source).expect("empty slur pair should recover");

    assert_balanced_xml(&export.musicxml);
    assert!(
        !export.musicxml.contains("<slur "),
        "empty () must not produce a MusicXML slur: {}",
        export.musicxml
    );
    assert!(
        export
            .diagnostics
            .iter()
            .any(|diagnostic| diagnostic.code == "abc.music.unmatched_slur"),
        "empty () should be diagnosed: {:?}",
        export.diagnostics
    );
}

#[test]
fn excessive_repeat_dots_resolve_to_their_unambiguous_direction() {
    // `|:::` opens a repeat and `:::|` closes one — the dot COUNT is the
    // unclear part (a play-count hint croma does not model), not the
    // direction. Erasing the repeat entirely lost playback structure; the
    // liberal-run classifier now keeps the direction.
    let source = "X:1\nM:4/4\nL:1/4\nK:C\n|::: C D E F :::|\n";
    let export = export_musicxml(source).expect("liberal repeat dots should recover");

    assert_balanced_xml(&export.musicxml);
    assert_eq!(
        count(&export.musicxml, "<repeat direction=\"forward\"/>"),
        1
    );
    assert_eq!(
        count(&export.musicxml, "<repeat direction=\"backward\"/>"),
        1
    );
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(measure_numbers(&measures), vec!["1"]);
    assert_eq!(note_steps(&measures[0]), vec!['C', 'D', 'E', 'F']);
}

#[test]
fn trailing_split_barline_does_not_emit_phantom_empty_measure() {
    let source = "X:1\nL:1/8\nK:C\nCDEF| |\n";
    let export = export_musicxml(source).expect("trailing split barline should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(measure_numbers(&measures), vec!["1"]);
    assert_eq!(note_steps(&measures[0]), vec!['C', 'D', 'E', 'F']);
}

#[test]
fn trailing_thin_thick_then_thin_barline_does_not_emit_phantom_measure() {
    let source = "X:1\nL:1/8\nK:C\nCDEF|]|\n";
    let export = export_musicxml(source).expect("trailing |]| should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(measure_numbers(&measures), vec!["1"]);
    assert_eq!(note_steps(&measures[0]), vec!['C', 'D', 'E', 'F']);
}

#[test]
fn trailing_split_barline_with_space_does_not_emit_phantom_measure() {
    let source = "X:1\nL:1/8\nK:C\nCDEF| | \n";
    let export = export_musicxml(source).expect("trailing split barline with space should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(measure_numbers(&measures), vec!["1"]);
    assert_eq!(note_steps(&measures[0]), vec!['C', 'D', 'E', 'F']);
}

#[test]
fn split_barline_between_content_is_a_single_boundary() {
    let source = "X:1\nL:1/8\nK:C\nCDEF| | GABc|\n";
    let export = export_musicxml(source).expect("interior split barline should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(measure_numbers(&measures), vec!["1", "2"]);
    assert_eq!(note_steps(&measures[0]), vec!['C', 'D', 'E', 'F']);
    assert_eq!(note_steps(&measures[1]), vec!['G', 'A', 'B', 'C']);
}

#[test]
fn continued_section_leading_double_barline_does_not_close_empty_measure() {
    // tune_006306 style: a pickup note uses a suppressed line break, then the
    // continued physical line starts a section with `||`; the next section
    // also starts with `||` after the previous one closed with `||`. That
    // second section marker keeps an empty section-leading measure, but must
    // not be reclassified as a right double barline on it.
    let source = "X:1\nM:3/4\nL:1/4\nK:Am\nE \\\n|| AA>c | e3 ||\n|| aa>a | a3 ||\n";
    let export = export_musicxml(source).expect("continued section double should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(
        measure_numbers(&measures),
        vec!["1", "2", "3", "4", "5", "6"]
    );
    assert_eq!(note_steps(&measures[0]), vec!['E']);
    assert_eq!(note_steps(&measures[1]), vec!['A', 'A', 'C']);
    assert_eq!(note_steps(&measures[2]), vec!['E']);
    assert!(measures[3].notes.is_empty());
    assert!(
        measures[3].barlines.is_empty(),
        "section-leading || empty measure must not carry barlines: {:?}",
        measures[3].barlines
    );
    assert_eq!(note_steps(&measures[4]), vec!['A', 'A', 'A']);
    assert_eq!(note_steps(&measures[5]), vec!['A']);
}

#[test]
fn thick_barline_then_repeat_start_run_is_a_single_boundary() {
    let source = "X:1\nL:1/8\nM:C\nK:C\nCDEF]||: GA |\n";
    let export = export_musicxml(source).expect("]||: barline run should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(measure_numbers(&measures), vec!["1", "2"]);
    assert_eq!(note_steps(&measures[0]), vec!['C', 'D', 'E', 'F']);
    assert_eq!(note_steps(&measures[1]), vec!['G', 'A']);
    assert!(has_barline(
        &measures[1],
        "left",
        Some("heavy-light"),
        Some("forward")
    ));
}

#[test]
fn thick_barline_run_after_second_ending_does_not_emit_phantom_measure() {
    let source = "X:1\nL:1/8\nM:C\nK:C\n[1 C2:|[2 C3]||: GA |\n";
    let export = export_musicxml(source).expect("second-ending ]||: run should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(measure_numbers(&measures), vec!["1", "2", "3"]);
    assert_eq!(note_steps(&measures[0]), vec!['C']);
    assert_eq!(note_steps(&measures[1]), vec!['C']);
    assert_eq!(note_steps(&measures[2]), vec!['G', 'A']);
    assert!(has_barline(
        &measures[2],
        "left",
        Some("heavy-light"),
        Some("forward")
    ));
}

#[test]
fn continued_thick_barline_run_after_second_ending_does_not_emit_phantom_measure() {
    let source = "X:1\nL:1/8\nM:C\nK:C\n[1 C2:|[2 C3]\\\n||: GA |\n";
    let export = export_musicxml(source).expect("continued second-ending ]||: run should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(measure_numbers(&measures), vec!["1", "2", "3"]);
    assert_eq!(note_steps(&measures[0]), vec!['C']);
    assert_eq!(note_steps(&measures[1]), vec!['C']);
    assert_eq!(note_steps(&measures[2]), vec!['G', 'A']);
    assert!(has_barline(
        &measures[2],
        "left",
        Some("heavy-light"),
        Some("forward")
    ));
}

#[test]
fn double_barline_repeat_start_between_content_is_a_single_boundary() {
    let source = "X:1\nL:1/8\nK:C\nCDEF||: GABc|\n";
    let export = export_musicxml(source).expect("||: repeat-start should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(measure_numbers(&measures), vec!["1", "2"]);
    assert_eq!(note_steps(&measures[0]), vec!['C', 'D', 'E', 'F']);
    assert_eq!(note_steps(&measures[1]), vec!['G', 'A', 'B', 'C']);
    assert!(has_barline(
        &measures[1],
        "left",
        Some("heavy-light"),
        Some("forward")
    ));
}

#[test]
fn mid_tune_final_then_repeat_start_does_not_emit_phantom_measure() {
    let source = "X:1\nL:1/8\nK:C\nCDEF|]\n|: GABc :|\n";
    let export = export_musicxml(source).expect("final then repeat-start should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(measure_numbers(&measures), vec!["1", "2"]);
    assert_eq!(note_steps(&measures[0]), vec!['C', 'D', 'E', 'F']);
    assert_eq!(note_steps(&measures[1]), vec!['G', 'A', 'B', 'C']);
    assert!(has_barline(
        &measures[1],
        "left",
        Some("heavy-light"),
        Some("forward")
    ));
    assert!(has_barline(
        &measures[1],
        "right",
        Some("light-heavy"),
        Some("backward")
    ));
}

#[test]
fn contiguous_double_barline_stays_single_measure() {
    let source = "X:1\nL:1/8\nK:C\nCDEF||\n";
    let export = export_musicxml(source).expect("double barline should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(measure_numbers(&measures), vec!["1"]);
    assert_eq!(note_steps(&measures[0]), vec!['C', 'D', 'E', 'F']);
}

#[test]
fn separate_content_lines_keep_two_measures() {
    let source = "X:1\nL:1/8\nK:C\nCDEF|\nGABc|\n";
    let export = export_musicxml(source).expect("two content lines should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(measure_numbers(&measures), vec!["1", "2"]);
    assert_eq!(note_steps(&measures[0]), vec!['C', 'D', 'E', 'F']);
    assert_eq!(note_steps(&measures[1]), vec!['G', 'A', 'B', 'C']);
}

#[test]
fn leading_rest_measure_is_preserved_not_phantom() {
    let source = "X:1\nL:1/8\nK:C\nz4|CDEF|\n";
    let export = export_musicxml(source).expect("leading rest measure should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(measure_numbers(&measures), vec!["1", "2"]);
    assert!(measures[0].notes.iter().any(|note| note.rest));
    assert_eq!(note_steps(&measures[1]), vec!['C', 'D', 'E', 'F']);
}

#[test]
fn consecutive_leading_barlines_keep_single_empty_pickup_measure() {
    // abc2xml preserves exactly one leading empty (pickup) measure; the extra
    // bar lines coalesce into the same boundary rather than opening phantoms.
    let source = "X:1\nL:1/8\nK:C\n| | | |CDEF|\n";
    let export = export_musicxml(source).expect("leading bar-line run should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(measure_numbers(&measures), vec!["1", "2"]);
    assert!(!measures[0].notes.iter().any(|note| note.rest));
    assert!(measures[0].notes.is_empty());
    assert_eq!(note_steps(&measures[1]), vec!['C', 'D', 'E', 'F']);
}

#[test]
fn multi_voice_tacet_barline_only_measure_is_kept_aligned() {
    // In a multi-voice score, a voice line that is only a bar line is a
    // legitimate *tacet* bar: the voice rests through that measure but must keep
    // an empty measure so it stays measure-aligned with its siblings (this is
    // exactly the corpus pattern `[V:4]  |` / `[V:2] [|]`). The single-voice
    // phantom-collapse must NOT fire here: with the over-aggressive
    // (ungated) bar-line-only coalescing, V2's trailing `| |` tacet measure is
    // wrongly popped and V2 shrinks to 2 measures, breaking alignment. The
    // single-voice gate keeps it at 3.
    //
    // Each `[V:n] ...` line continues that voice. V2's final continuation line
    // is a bare bar-line run (`| |`) — a bar-line-only trailing measure that the
    // `finish()` trailing-pop would remove in single-voice music, but must be
    // preserved here.
    let source = concat!(
        "X:1\nM:2/4\nL:1/8\nK:C\n",
        "[V:1] CDEF |\n[V:2] EFGA |\n",
        "[V:1] GABc |\n[V:2] EDCB, |\n",
        "[V:1] cBAG |\n[V:2] | |\n",
    );
    let export = export_musicxml(source).expect("multi-voice tacet bar should export");
    assert_balanced_xml(&export.musicxml);

    let parts = part_bodies(&export.musicxml);
    assert_eq!(parts.len(), 2, "expected two voices/parts");

    let v1_measures = musicxml_measures(&parts[0]);
    let v2_measures = musicxml_measures(&parts[1]);
    assert_eq!(
        v1_measures.len(),
        3,
        "V1 should have three measures: {:?}",
        measure_numbers(&v1_measures)
    );
    assert_eq!(
        v2_measures.len(),
        v1_measures.len(),
        "V2 tacet bar must be kept so voices stay measure-aligned: {:?} vs {:?}",
        measure_numbers(&v2_measures),
        measure_numbers(&v1_measures)
    );
    // The trailing (third) tacet measure of V2 carries no sounding notes.
    assert!(
        v2_measures[2].notes.iter().all(|note| note.rest) || v2_measures[2].notes.is_empty(),
        "V2 trailing measure should be a tacet bar, not real notes"
    );
}

#[test]
fn multi_voice_empty_final_measure_keeps_final_barline() {
    // A final continuation line that is only `|]` still explicitly notates a
    // final bar for that voice. Keep the empty measure and its right barline.
    let source = concat!(
        "X:1\nM:2/4\nL:1/8\nK:C\n",
        "[V:1] CDEF |\n[V:2] EFGA |\n",
        "[V:1] GABc |\n[V:2] |]\n",
    );
    let export = export_musicxml(source).expect("multi-voice empty final bar should export");
    assert_balanced_xml(&export.musicxml);

    let parts = part_bodies(&export.musicxml);
    assert_eq!(parts.len(), 2, "expected two voices/parts");

    let v1_measures = musicxml_measures(&parts[0]);
    let v2_measures = musicxml_measures(&parts[1]);
    assert_eq!(v1_measures.len(), 2);
    assert_eq!(v2_measures.len(), 2);
    assert!(v2_measures[1].notes.is_empty());
    assert!(
        has_barline(&v2_measures[1], "right", Some("light-heavy"), None),
        "V2 empty final measure should retain its explicit |] right barline: {:?}",
        v2_measures[1].barlines
    );
}

#[test]
fn multi_voice_empty_measure_keeps_invisible_barline() {
    // tune_006759: a note-less measure whose only token is `[|]` (an invisible
    // bar) in one voice must export <bar-style>none</bar-style>, exactly like the
    // `|]` empty-final case — croma dropped it because only Final kept the empty
    // measure's span start (the barline then led its measure). ABC 2.1 §4.8 line
    // 999 (`[|]` invisible bar line → bar-style none).
    let source = concat!(
        "X:1\nM:2/4\nL:1/8\nK:C\n",
        "[V:1] CDEF |\n[V:2] EFGA |\n",
        "[V:1] GABc |\n[V:2] [|]\n",
    );
    let export = export_musicxml(source).expect("multi-voice empty [|] should export");
    assert_balanced_xml(&export.musicxml);

    let parts = part_bodies(&export.musicxml);
    let v2_measures = musicxml_measures(&parts[1]);
    assert_eq!(v2_measures.len(), 2);
    assert!(v2_measures[1].notes.is_empty());
    assert!(
        has_barline(&v2_measures[1], "right", Some("none"), None),
        "V2 empty measure must retain its [|] none barline: {:?}",
        v2_measures[1].barlines
    );
}

#[test]
fn words_containing_double_colon_pipe_do_not_emit_repeat_barlines() {
    let source = "X:1\nM:4/4\nL:1/4\nK:C\nC D E F |]\nW::| Cross over two couples\n";
    let export = export_musicxml(source).expect("words field should not affect barlines");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(measure_numbers(&measures), vec!["1"]);
    assert_eq!(note_steps(&measures[0]), vec!['C', 'D', 'E', 'F']);
    assert!(has_barline(
        &measures[0],
        "right",
        Some("light-heavy"),
        None
    ));
    assert!(
        measures[0]
            .barlines
            .iter()
            .all(|barline| barline.repeat_direction.is_none())
    );
}

#[test]
fn tune_014868_style_leading_double_repeat_has_no_empty_measure() {
    let source = concat!(
        "X:260\n",
        "T:Bag o' Spuds -- Am\n",
        "M:4/4\n",
        "R:Reel\n",
        "K:Am\n",
        "||:\"Am\"A2eA BAeA|ABcd ecdB|\"G\"G2BG DGBG|GABc \"Em\"dBcB|\n",
        "\"Am\"A2eA BAeA|ABcd \"G\"ecdB|\"F\"ABcd efge|\"Em\"dBGB BAA2:|\n",
        "|:\"Am\"a2ea ageg|agbg agef|\"G\"gedc BGBd|g2ga bgeg|\n",
        "\"Am\"a2ea ageg|agbg ageg|\"G\"d3e g3e|\"Em\"dBGB BAA2:|\n",
    );
    let export = export_musicxml(source).expect("leading combined repeat should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(
        measure_numbers(&measures),
        (1..=16)
            .map(|number| number.to_string())
            .collect::<Vec<_>>()
    );
    assert_eq!(
        note_steps(&measures[0]),
        vec!['A', 'E', 'A', 'B', 'A', 'E', 'A']
    );
    assert_eq!(note_durations(&measures[0]), vec![8, 4, 4, 4, 4, 4, 4]);
    assert!(has_barline(
        &measures[0],
        "left",
        Some("heavy-light"),
        Some("forward")
    ));
    assert!(has_barline(
        &measures[7],
        "right",
        Some("light-heavy"),
        Some("backward")
    ));
    assert!(has_barline(
        &measures[8],
        "left",
        Some("heavy-light"),
        Some("forward")
    ));
    assert!(has_barline(
        &measures[15],
        "right",
        Some("light-heavy"),
        Some("backward")
    ));
    assert!(!measures[0].notes.iter().any(|note| note.rest));
}

#[test]
fn bracketed_repeat_start_and_final_repeat_end_export_repeat_edges() {
    let source = "X:1\nM:4/4\nL:1/4\nK:C\n[|: C D E F :|]\n";
    let export = export_musicxml(source).expect("bracketed repeat barlines should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(measure_numbers(&measures), vec!["1"]);
    assert!(has_barline(
        &measures[0],
        "left",
        Some("heavy-light"),
        Some("forward")
    ));
    assert!(has_barline(
        &measures[0],
        "right",
        Some("light-heavy"),
        Some("backward")
    ));
}

#[test]
fn pickup_repeat_start_places_forward_repeat_on_repeated_section() {
    // ABC 2.1 §6: `|:` after a pickup marks the START of the repeated
    // section, so the forward repeat belongs to the LEFT of measure 2
    // (`CDEF`), not the pickup measure 1 (`E`).
    let source = "X:1\nM:4/4\nL:1/4\nK:C\nE|:CDEF|GABc:|]\n";
    let export = export_musicxml(source).expect("pickup repeat should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(measure_numbers(&measures), vec!["1", "2", "3"]);
    assert_eq!(note_steps(&measures[0]), vec!['E']);
    assert_eq!(note_steps(&measures[1]), vec!['C', 'D', 'E', 'F']);
    assert!(
        !has_barline(&measures[0], "left", Some("heavy-light"), Some("forward")),
        "pickup measure must not carry the forward repeat"
    );
    assert!(has_barline(
        &measures[1],
        "left",
        Some("heavy-light"),
        Some("forward")
    ));
    assert!(has_barline(
        &measures[2],
        "right",
        Some("light-heavy"),
        Some("backward")
    ));
}

#[test]
fn mid_tune_repeat_start_places_forward_repeat_on_repeated_section() {
    // `|:` after content mid-tune marks the start of the repeated section:
    // forward repeat belongs to the LEFT of measure 3 (`cBAG`).
    let source = "X:1\nM:4/4\nL:1/4\nK:C\nCDEF|GABc|:cBAG|FEDC:|]\n";
    let export = export_musicxml(source).expect("mid-tune repeat should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(measure_numbers(&measures), vec!["1", "2", "3", "4"]);
    assert_eq!(note_steps(&measures[1]), vec!['G', 'A', 'B', 'C']);
    assert_eq!(note_steps(&measures[2]), vec!['C', 'B', 'A', 'G']);
    assert!(
        !has_barline(&measures[1], "left", Some("heavy-light"), Some("forward")),
        "measure preceding the repeat must not carry the forward repeat"
    );
    assert!(has_barline(
        &measures[2],
        "left",
        Some("heavy-light"),
        Some("forward")
    ));
    assert!(has_barline(
        &measures[3],
        "right",
        Some("light-heavy"),
        Some("backward")
    ));
}

#[test]
fn leading_repeat_start_after_header_stays_on_its_own_measure() {
    // Non-regression: a `|:` with no preceding content in its measure is a
    // legitimate LEFT barline of measure 1 and must stay there.
    let source = "X:1\nM:4/4\nL:1/4\nK:C\n|:CDEF|GABc:|]\n";
    let export = export_musicxml(source).expect("leading repeat should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(measure_numbers(&measures), vec!["1", "2"]);
    assert_eq!(note_steps(&measures[0]), vec!['C', 'D', 'E', 'F']);
    assert!(has_barline(
        &measures[0],
        "left",
        Some("heavy-light"),
        Some("forward")
    ));
    assert!(has_barline(
        &measures[1],
        "right",
        Some("light-heavy"),
        Some("backward")
    ));
}

#[test]
fn double_then_repeat_start_after_content_defers_forward_repeat() {
    // `||:` after content (`Double` + `RepeatStart`) must not drop the
    // forward repeat and must place it on the measure beginning the body.
    let source = "X:1\nM:4/4\nL:1/4\nK:C\nCDEF||:GABc|cBAG:|]\n";
    let export = export_musicxml(source).expect("double-then-repeat should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(measure_numbers(&measures), vec!["1", "2", "3"]);
    assert_eq!(note_steps(&measures[0]), vec!['C', 'D', 'E', 'F']);
    assert_eq!(note_steps(&measures[1]), vec!['G', 'A', 'B', 'C']);
    assert!(
        !has_barline(&measures[0], "left", Some("heavy-light"), Some("forward")),
        "first measure must not carry the forward repeat"
    );
    assert!(has_barline(
        &measures[1],
        "left",
        Some("heavy-light"),
        Some("forward")
    ));
    assert!(has_barline(
        &measures[2],
        "right",
        Some("light-heavy"),
        Some("backward")
    ));
}

#[test]
fn section_final_barline_followed_by_regular_is_preserved() {
    // Bug C: `|]` (light-heavy section barline) immediately followed by `|`
    // must be kept as the RIGHT barline of its measure (`GABc`).
    let source = "X:1\nM:4/4\nL:1/4\nK:C\nCDEF|GABc|]|cBAG|FEDC|]\n";
    let export = export_musicxml(source).expect("section final barline should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(note_steps(&measures[1]), vec!['G', 'A', 'B', 'C']);
    assert!(
        has_barline(&measures[1], "right", Some("light-heavy"), None),
        "the section final barline after measure 2 must be preserved"
    );
}

#[test]
fn trailing_section_final_then_regular_spelling_keeps_final_barline() {
    let source = "X:1\nL:1/8\nK:C\nCDEF|]|\n";
    let export = export_musicxml(source).expect("trailing |]| should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(measure_numbers(&measures), vec!["1"]);
    assert_eq!(note_steps(&measures[0]), vec!['C', 'D', 'E', 'F']);
    assert!(has_barline(
        &measures[0],
        "right",
        Some("light-heavy"),
        None
    ));
}

#[test]
fn adjacent_repeat_end_and_second_ending_starts_next_measure() {
    let source = "X:1\nM:4/4\nL:1/4\nK:C\n|: C D E F |1 G A B c :|2 D E F G |]\n";
    let export = export_musicxml(source).expect("adjacent repeat ending should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(measure_numbers(&measures), vec!["1", "2", "3"]);
    assert!(has_ending(&measures[1], "left", "1", "start"));
    assert!(has_ending(&measures[1], "right", "1", "stop"));
    assert!(has_ending(&measures[2], "left", "2", "start"));
    assert!(has_ending(&measures[2], "right", "2", "stop"));
    assert!(has_barline(
        &measures[1],
        "right",
        Some("light-heavy"),
        Some("backward")
    ));
}

#[test]
fn internal_rest_measure_is_preserved_after_leading_barline_policy() {
    let source = "X:1\nM:4/4\nL:1/4\nK:C\nC D E F | z4 | G A B c |]\n";
    let export = export_musicxml(source).expect("internal rest measure should export");

    assert_balanced_xml(&export.musicxml);
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(measure_numbers(&measures), vec!["1", "2", "3"]);
    assert_eq!(note_steps(&measures[0]), vec!['C', 'D', 'E', 'F']);
    assert_eq!(measures[1].notes.len(), 1);
    assert!(measures[1].notes[0].rest);
    assert_eq!(measures[1].notes[0].duration, Some(32));
    assert_eq!(note_steps(&measures[2]), vec!['G', 'A', 'B', 'C']);
}

#[test]
fn chords_grace_tuplets_ties_slurs_and_lyrics_export() {
    let source =
        "X:1\nT:Features\nM:4/4\nL:1/8\nK:C\n{g}[CEG] (3D-D F (G A)|\nw: chord trip let slur end\n";
    let export = export_musicxml(source).expect("feature score should export");

    assert_balanced_xml(&export.musicxml);
    assert_eq!(count(&export.musicxml, "<chord/>"), 2);
    assert!(export.musicxml.contains("<grace/>"));
    assert!(export.musicxml.contains("<time-modification>"));
    assert!(export.musicxml.contains("<actual-notes>3</actual-notes>"));
    assert!(export.musicxml.contains("<normal-notes>2</normal-notes>"));
    assert!(export.musicxml.contains("<tuplet type=\"start\""));
    assert!(export.musicxml.contains("<tuplet type=\"stop\""));
    assert!(export.musicxml.contains("<tie type=\"start\"/>"));
    assert!(export.musicxml.contains("<tied type=\"start\""));
    assert!(export.musicxml.contains("<slur type=\"start\""));
    assert!(export.musicxml.contains("<slur type=\"stop\""));
    assert!(export.musicxml.contains("<text>chord</text>"));
}

#[test]
fn slur_opening_before_grace_group_starts_on_first_grace_note() {
    // ABC 2.1 §4.11 + §4.20: in `({grace}note)` the slur opens before the grace
    // group, so it must start on the FIRST grace note, not the following main
    // note. The main note carries only the matching stop.
    let source = "X:1\nT:Grace Slur\nM:4/4\nL:1/4\nK:C\n({g}c4)|\n";
    let export = export_musicxml(source).expect("grace-slur score should export");

    assert_balanced_xml(&export.musicxml);

    // The grace note carries the slur start inside its own notations.
    let grace_note_start = export
        .musicxml
        .find("<grace/>")
        .expect("grace note should be present");
    let grace_note_end = export.musicxml[grace_note_start..]
        .find("</note>")
        .map(|offset| grace_note_start + offset)
        .expect("grace note should be terminated");
    let grace_note = &export.musicxml[grace_note_start..grace_note_end];
    assert!(
        grace_note.contains("<notations>"),
        "grace note should carry notations: {grace_note}"
    );
    assert!(
        grace_note.contains("<slur type=\"start\" number=\"1\"/>"),
        "grace note should carry the slur start: {grace_note}"
    );

    // There is exactly one slur start and one slur stop overall (no degenerate
    // start+stop on the main note).
    assert_eq!(
        count(&export.musicxml, "<slur type=\"start\" number=\"1\"/>"),
        1
    );
    assert_eq!(
        count(&export.musicxml, "<slur type=\"stop\" number=\"1\"/>"),
        1
    );

    // The slur stop lands on the main note, which is NOT a grace note.
    let stop_index = export
        .musicxml
        .find("<slur type=\"stop\" number=\"1\"/>")
        .expect("slur stop should be present");
    let main_note_start = export.musicxml[..stop_index]
        .rfind("<note")
        .expect("slur stop should be inside a note");
    let main_note = &export.musicxml[main_note_start..stop_index];
    assert!(
        !main_note.contains("<grace/>"),
        "slur stop should land on the main note, not a grace note: {main_note}"
    );
    assert!(
        !main_note.contains("<slur type=\"start\""),
        "main note must not carry a degenerate slur start: {main_note}"
    );
}

#[test]
fn bare_grace_slur_exports_start_and_stop_on_grace_notes() {
    let source = "X:1\nT:Bare Grace Slur\nM:4/4\nL:1/8\nK:C\n({Bc})D|\n";
    let export = export_musicxml(source).expect("bare grace slur score should export");

    assert_balanced_xml(&export.musicxml);
    assert!(
        export
            .diagnostics
            .iter()
            .all(|diagnostic| diagnostic.code != "abc.music.unmatched_slur"),
        "bare grace slur should not warn: {:?}",
        export.diagnostics
    );
    assert_eq!(
        count(&export.musicxml, "<slur type=\"start\" number=\"1\"/>"),
        1
    );
    assert_eq!(
        count(&export.musicxml, "<slur type=\"stop\" number=\"1\"/>"),
        1
    );

    let note_blocks = musicxml_note_blocks(&export.musicxml);
    let grace_blocks = note_blocks
        .iter()
        .filter(|note| note.contains("<grace"))
        .copied()
        .collect::<Vec<_>>();
    assert_eq!(grace_blocks.len(), 2);
    assert!(
        grace_blocks[0].contains("<slur type=\"start\" number=\"1\"/>"),
        "first grace note should open the slur: {}",
        grace_blocks[0]
    );
    assert!(
        grace_blocks[1].contains("<slur type=\"stop\" number=\"1\"/>"),
        "last grace note should close the slur: {}",
        grace_blocks[1]
    );
    let main_note = note_blocks
        .iter()
        .find(|note| !note.contains("<grace"))
        .expect("main note should be present");
    assert!(
        !main_note.contains("<slur"),
        "main note should not carry the bare grace slur: {main_note}"
    );
}

#[test]
fn bare_grace_slur_before_barline_exports_as_after_grace_pair() {
    let source = "X:1\nT:Bare Grace Before Bar\nM:4/4\nL:1/8\nK:C\nA2({Bc})|\n";
    let export = export_musicxml(source).expect("bare grace before barline should export");

    assert_balanced_xml(&export.musicxml);
    assert!(
        export
            .diagnostics
            .iter()
            .all(|diagnostic| diagnostic.code != "abc.music.dangling_grace_group"),
        "bare grace before barline should not dangle: {:?}",
        export.diagnostics
    );
    let note_blocks = musicxml_note_blocks(&export.musicxml);
    let main_note = note_blocks
        .iter()
        .find(|note| !note.contains("<grace"))
        .expect("main note should be present");
    assert!(
        !main_note.contains("<slur"),
        "main note should not carry the bare grace slur: {main_note}"
    );
    let grace_blocks = note_blocks
        .iter()
        .filter(|note| note.contains("<grace"))
        .copied()
        .collect::<Vec<_>>();
    assert_eq!(grace_blocks.len(), 2);
    assert!(grace_blocks[0].contains("<slur type=\"start\" number=\"1\"/>"));
    assert!(grace_blocks[1].contains("<slur type=\"stop\" number=\"1\"/>"));
}

#[test]
fn slur_opening_after_a_grace_starts_on_main_note_not_grace() {
    // Discrimination guard: `{g}c(de)` — the grace `{g}` leads note `c`, but the
    // slur opens AFTER it, before `d`. The slurred note `d` has NO leading grace
    // group, so the slur must start on `d` and the grace `G` must stay clean.
    // This proves the span check only re-targets a grace the slur actually
    // encloses (the `({grace}note)` form in
    // `slur_opening_before_grace_group_starts_on_first_grace_note`).
    let source = "X:1\nT:Grace Then Slur\nM:4/4\nL:1/4\nK:C\n{g}c(de)|\n";
    let export = export_musicxml(source).expect("grace-then-slur score should export");

    assert_balanced_xml(&export.musicxml);

    // The grace G carries no slur.
    let grace_note_start = export
        .musicxml
        .find("<grace/>")
        .expect("grace note should be present");
    let grace_note_end = export.musicxml[grace_note_start..]
        .find("</note>")
        .map(|offset| grace_note_start + offset)
        .expect("grace note should be terminated");
    let grace_note = &export.musicxml[grace_note_start..grace_note_end];
    assert!(
        !grace_note.contains("<slur"),
        "grace note must not carry a slur when the slur opens after it: {grace_note}"
    );

    // The slur start lands on a non-grace main note (D).
    let start_index = export
        .musicxml
        .find("<slur type=\"start\" number=\"1\"/>")
        .expect("slur start should be present");
    let main_note_start = export.musicxml[..start_index]
        .rfind("<note")
        .expect("slur start should be inside a note");
    let main_note = &export.musicxml[main_note_start..start_index];
    assert!(
        !main_note.contains("<grace/>"),
        "slur start should land on the main note: {main_note}"
    );
    assert!(
        export
            .musicxml
            .contains("<slur type=\"stop\" number=\"1\"/>")
    );
}

#[test]
fn slur_without_grace_still_spans_first_to_last_note() {
    // Guard: a plain `(DEF)` slur still spans D->F unchanged, and a plain grace
    // note with no slur emits no notations.
    let source = "X:1\nT:Plain Slur\nM:4/4\nL:1/4\nK:C\n(DEF) {g}A|\n";
    let export = export_musicxml(source).expect("plain slur score should export");

    assert_balanced_xml(&export.musicxml);
    assert_eq!(
        count(&export.musicxml, "<slur type=\"start\" number=\"1\"/>"),
        1
    );
    assert_eq!(
        count(&export.musicxml, "<slur type=\"stop\" number=\"1\"/>"),
        1
    );

    // The lone grace note (the `{g}` before `A`) carries no notations/slur.
    let grace_note_start = export
        .musicxml
        .find("<grace/>")
        .expect("grace note should be present");
    let grace_note_end = export.musicxml[grace_note_start..]
        .find("</note>")
        .map(|offset| grace_note_start + offset)
        .expect("grace note should be terminated");
    let grace_note = &export.musicxml[grace_note_start..grace_note_end];
    assert!(
        !grace_note.contains("<notations>") && !grace_note.contains("<slur"),
        "plain grace note should carry no notations: {grace_note}"
    );
}

#[test]
fn grace_before_slur_open_attaches_to_main_note_and_slur_starts_on_main() {
    // ABC 2.1 §4.20: in `{g}(c4)` the grace group precedes the note `c`, so it
    // attaches to `c`. The slur `(` opens AFTER the grace, so the slur starts on
    // the MAIN note `c`, not on the grace. Before the fix the intervening slur
    // flushed the grace into a standalone item that lowering dropped, losing the
    // grace entirely.
    let source = "X:1\nT:Grace Before Slur\nM:4/4\nL:1/8\nK:C\n{g}(c4) d|\n";
    let export = export_musicxml(source).expect("grace-before-slur score should export");

    assert_balanced_xml(&export.musicxml);

    // The grace note is preserved.
    let grace_note_start = export
        .musicxml
        .find("<grace/>")
        .expect("grace note should be present");
    let grace_note_end = export.musicxml[grace_note_start..]
        .find("</note>")
        .map(|offset| grace_note_start + offset)
        .expect("grace note should be terminated");
    let grace_note = &export.musicxml[grace_note_start..grace_note_end];

    // The grace carries no slur — the slur opens after it.
    assert!(
        !grace_note.contains("<slur"),
        "grace note must not carry a slur when the slur opens after it: {grace_note}"
    );

    // Exactly one slur start/stop overall.
    assert_eq!(
        count(&export.musicxml, "<slur type=\"start\" number=\"1\"/>"),
        1
    );
    assert_eq!(
        count(&export.musicxml, "<slur type=\"stop\" number=\"1\"/>"),
        1
    );

    // The slur start lands on the main note `c`, which is NOT a grace note.
    let start_index = export
        .musicxml
        .find("<slur type=\"start\" number=\"1\"/>")
        .expect("slur start should be present");
    let main_note_start = export.musicxml[..start_index]
        .rfind("<note")
        .expect("slur start should be inside a note");
    let main_note = &export.musicxml[main_note_start..start_index];
    assert!(
        !main_note.contains("<grace/>"),
        "slur start should land on the main note, not the grace: {main_note}"
    );
}

#[test]
fn grace_before_slur_open_does_not_regress_slur_before_grace() {
    // Phase-18 guard: `({g}c4)` keeps the slur start on the FIRST grace note. The
    // grace-before-slur fix must not disturb this path.
    let source = "X:1\nT:Slur Before Grace\nM:4/4\nL:1/4\nK:C\n({g}c4) d|\n";
    let export = export_musicxml(source).expect("slur-before-grace score should export");

    assert_balanced_xml(&export.musicxml);

    let grace_note_start = export
        .musicxml
        .find("<grace/>")
        .expect("grace note should be present");
    let grace_note_end = export.musicxml[grace_note_start..]
        .find("</note>")
        .map(|offset| grace_note_start + offset)
        .expect("grace note should be terminated");
    let grace_note = &export.musicxml[grace_note_start..grace_note_end];
    assert!(
        grace_note.contains("<slur type=\"start\" number=\"1\"/>"),
        "slur opening before the grace should start on the grace note: {grace_note}"
    );
}

#[test]
fn grace_before_plain_note_still_attaches() {
    // Control: `{g}c4 d` (no slur) keeps the grace on `c` and emits no slur.
    let source = "X:1\nT:Plain Grace\nM:4/4\nL:1/8\nK:C\n{g}c4 d|\n";
    let export = export_musicxml(source).expect("plain grace score should export");

    assert_balanced_xml(&export.musicxml);
    assert_eq!(count(&export.musicxml, "<grace/>"), 1);
    assert!(!export.musicxml.contains("<slur"));
}

#[test]
fn grace_group_internal_slurs_export_on_grace_notes_in_source_order() {
    let source = "X:1\nT:Grace Internal Slurs\nM:4/4\nL:1/8\nK:C\n{(fg)}a2 {(ef)}g2|]\n";
    let export = export_musicxml(source).expect("grace-internal slur score should export");

    assert_balanced_xml(&export.musicxml);
    assert_eq!(
        export
            .diagnostics
            .iter()
            .filter(|diagnostic| diagnostic.code == "abc.music.unknown_grace_token")
            .count(),
        0,
        "grace-internal slur markers should not warn: {:?}",
        export.diagnostics
    );
    assert_eq!(count(&export.musicxml, "<grace/>"), 4);
    assert_eq!(count(&export.musicxml, "<slur type=\"start\""), 2);
    assert_eq!(count(&export.musicxml, "<slur type=\"stop\""), 2);

    let note_blocks = musicxml_note_blocks(&export.musicxml);
    let grace_slurs = note_blocks
        .iter()
        .filter(|note| note.contains("<grace"))
        .map(|note| {
            (
                element_text(note, "step")
                    .and_then(|text| text.chars().next())
                    .expect("grace note should have a pitch step"),
                note.contains("<slur type=\"start\""),
                note.contains("<slur type=\"stop\""),
            )
        })
        .collect::<Vec<_>>();
    assert_eq!(
        grace_slurs,
        vec![
            ('F', true, false),
            ('G', false, true),
            ('E', true, false),
            ('F', false, true),
        ]
    );
    assert!(
        note_blocks
            .iter()
            .filter(|note| !note.contains("<grace"))
            .all(|note| !note.contains("<slur")),
        "main notes should not receive the grace-internal slurs"
    );
}

#[test]
fn malformed_grace_group_internal_slurs_warn_without_musicxml_slur_pairs() {
    for (source, code, marker) in [
        (
            "X:1\nT:t\nM:4/4\nK:C\n{(fg}a|]\n",
            "abc.music.unclosed_slur",
            "(",
        ),
        (
            "X:1\nT:t\nM:4/4\nK:C\n{fg)}a|]\n",
            "abc.music.unmatched_slur",
            ")",
        ),
    ] {
        let export = export_musicxml(source).expect("malformed grace slur should recover");

        assert_balanced_xml(&export.musicxml);
        assert_diagnostic_span(source, &export.diagnostics, code, marker);
        assert_eq!(count(&export.musicxml, "<grace/>"), 2);
        assert!(
            !export.musicxml.contains("<slur "),
            "malformed grace-internal slur should not export an unpaired MusicXML slur: {}",
            export.musicxml
        );
    }
}

#[test]
fn grace_group_internal_slur_can_export_from_chord_to_note() {
    let source = "X:1\nT:Grace Chord Slur\nM:4/4\nL:1/8\nK:C\n{([fg]a)}c|]\n";
    let export = export_musicxml(source).expect("grace chord slur should export");

    assert_balanced_xml(&export.musicxml);
    assert!(export.diagnostics.is_empty(), "{:?}", export.diagnostics);
    assert_eq!(count(&export.musicxml, "<grace/>"), 3);
    assert_eq!(count(&export.musicxml, "<slur type=\"start\""), 1);
    assert_eq!(count(&export.musicxml, "<slur type=\"stop\""), 1);

    let grace_slurs = musicxml_note_blocks(&export.musicxml)
        .iter()
        .filter(|note| note.contains("<grace"))
        .map(|note| {
            (
                element_text(note, "step")
                    .and_then(|text| text.chars().next())
                    .expect("grace note should have a pitch step"),
                note.contains("<chord/>"),
                note.contains("<slur type=\"start\""),
                note.contains("<slur type=\"stop\""),
            )
        })
        .collect::<Vec<_>>();
    assert_eq!(
        grace_slurs,
        vec![
            ('F', false, true, false),
            ('G', true, false, false),
            ('A', false, false, true),
        ]
    );
}

#[test]
fn grace_group_internal_slur_across_rest_stays_on_grace_note() {
    let source = "X:1\nT:Grace Rest Slur\nM:4/4\nL:1/8\nK:C\n{(fz)}a|]\n";
    let export = export_musicxml(source).expect("grace rest slur should export");

    assert_balanced_xml(&export.musicxml);
    assert!(export.diagnostics.is_empty(), "{:?}", export.diagnostics);
    assert_eq!(count(&export.musicxml, "<grace/>"), 2);
    assert_eq!(count(&export.musicxml, "<slur type=\"start\""), 1);
    assert_eq!(count(&export.musicxml, "<slur type=\"stop\""), 1);

    let note_blocks = musicxml_note_blocks(&export.musicxml);
    let grace_slurs = note_blocks
        .iter()
        .filter(|note| note.contains("<grace"))
        .map(|note| {
            (
                element_text(note, "step").and_then(|text| text.chars().next()),
                note.contains("<rest/>"),
                note.contains("<slur type=\"start\""),
                note.contains("<slur type=\"stop\""),
            )
        })
        .collect::<Vec<_>>();
    assert_eq!(
        grace_slurs,
        vec![(Some('F'), false, true, true), (None, true, false, false)]
    );
    assert!(
        note_blocks
            .iter()
            .filter(|note| !note.contains("<grace"))
            .all(|note| !note.contains("<slur")),
        "rest-adjacent grace slur should not leak to the main note"
    );
}

#[test]
fn chord_symbol_before_grace_group_emits_harmony() {
    // `"F"{AB}c4`: the chord symbol written before the grace group binds to
    // the main note `c` and exports as a <harmony>; before the fix the first
    // grace note inside the braces stole the pending quoted text and lowering
    // silently dropped it.
    let source = "X:1\nT:Harmony Before Grace\nM:4/4\nL:1/8\nK:C\n\"F\"{AB}c4 d|\n";
    let export = export_musicxml(source).expect("harmony-before-grace score should export");

    assert_balanced_xml(&export.musicxml);
    assert_eq!(count(&export.musicxml, "<harmony"), 1);
    assert_eq!(count(&export.musicxml, "<grace/>"), 2);
}

#[test]
fn grace_orphaned_before_barline_is_voided_without_panic() {
    // A grace flushed at a barline now carries across the bar to the next note
    // (`c {g}| d` decorates the d — see
    // grace_group_before_barline_attaches_to_note_across_the_bar). Only a
    // grace with NO following note at all (end of tune) is void: no panic, no
    // `<grace/>`, and a dangling-grace diagnostic instead of a silent drop.
    let carried = export_musicxml("X:1\nT:Orphan Grace Bar\nM:4/4\nL:1/4\nK:C\nc {g}| d|\n")
        .expect("carried-grace score should export");
    assert_balanced_xml(&carried.musicxml);
    assert_eq!(count(&carried.musicxml, "<grace/>"), 1);

    let orphan = export_musicxml("X:1\nT:Orphan Grace End\nM:4/4\nL:1/4\nK:C\nc {g}\n")
        .expect("orphan-grace score should export");
    assert_balanced_xml(&orphan.musicxml);
    assert_eq!(count(&orphan.musicxml, "<grace/>"), 0);
    assert!(
        orphan
            .diagnostics
            .iter()
            .any(|d| d.code == "abc.music.dangling_grace_group")
    );
}

#[test]
fn lyric_hyphen_controls_do_not_export_as_sung_text() {
    let source = "X:1\nT:Hyphen Lyrics\nM:4/4\nL:1/4\nK:C\nC D E F|\nw: A-des-te fi-del\n";
    let export = export_musicxml(source).expect("hyphen lyric score should export");

    assert_balanced_xml(&export.musicxml);
    assert_eq!(count(&export.musicxml, "<lyric number=\"1\">"), 4);
    assert!(export.musicxml.contains("<text>A</text>"));
    assert!(export.musicxml.contains("<text>des</text>"));
    assert!(export.musicxml.contains("<text>te</text>"));
    assert!(export.musicxml.contains("<text>fi</text>"));
    assert!(!export.musicxml.contains("<text>del</text>"));
    assert!(!export.musicxml.contains("<text>-</text>"));
    assert_eq!(
        export
            .diagnostics
            .iter()
            .filter(|diagnostic| diagnostic.code == "abc.lyric.syllable_count")
            .count(),
        1
    );
}

#[test]
fn lyric_hyphen_controls_export_syllabic_begin_middle_end() {
    let source = "X:1\nT:Syllabic Lyrics\nM:4/4\nL:1/4\nK:C\nC D E F|\nw: A-des-te all\n";
    let export = export_musicxml(source).expect("hyphen lyric score should export");

    assert_balanced_xml(&export.musicxml);
    let syllabic = export
        .musicxml
        .match_indices("<syllabic>")
        .map(|(index, _)| {
            let start = index + "<syllabic>".len();
            let end = export.musicxml[start..]
                .find("</syllabic>")
                .map(|offset| start + offset)
                .expect("syllabic should close");
            &export.musicxml[start..end]
        })
        .collect::<Vec<_>>();
    assert_eq!(syllabic, vec!["begin", "middle", "end", "single"]);
    assert!(export.musicxml.contains("<text>A</text>"));
    assert!(export.musicxml.contains("<text>des</text>"));
    assert!(export.musicxml.contains("<text>te</text>"));
    assert!(export.musicxml.contains("<text>all</text>"));
    assert!(!export.musicxml.contains("<text>-</text>"));
    assert!(export.diagnostics.is_empty());
}

#[test]
fn trailing_lyric_hyphen_keeps_syllabic_word_open() {
    fn syllabic_values(source: &str) -> Vec<String> {
        let export = export_musicxml(source).expect("trailing-hyphen lyric score should export");
        assert_balanced_xml(&export.musicxml);
        assert!(export.diagnostics.is_empty());
        export
            .musicxml
            .match_indices("<syllabic>")
            .map(|(index, _)| {
                let start = index + "<syllabic>".len();
                let end = export.musicxml[start..]
                    .find("</syllabic>")
                    .map(|offset| start + offset)
                    .expect("syllabic should close");
                export.musicxml[start..end].to_owned()
            })
            .collect()
    }

    assert_eq!(
        syllabic_values("X:1\nT:Trailing Begin\nM:2/4\nL:1/4\nK:C\nC D|\nw: be-*\n"),
        vec!["begin"]
    );
    assert_eq!(
        syllabic_values("X:1\nT:Trailing Middle\nM:2/4\nL:1/4\nK:C\nC D|\nw: au- di-\n"),
        vec!["begin", "middle"]
    );
}

#[test]
fn orphan_lyric_hyphen_does_not_start_syllabic_word() {
    let source = "X:1\nT:Orphan Hyphen\nM:1/4\nL:1/4\nK:C\nC|\nw: a-b\n";
    let export = export_musicxml(source).expect("orphan hyphen score should export");

    assert_balanced_xml(&export.musicxml);
    assert!(export.musicxml.contains("<syllabic>single</syllabic>"));
    assert!(export.musicxml.contains("<text>a</text>"));
    assert!(!export.musicxml.contains("<syllabic>begin</syllabic>"));
    assert!(!export.musicxml.contains("<text>b</text>"));
    assert_eq!(
        export
            .diagnostics
            .iter()
            .filter(|diagnostic| diagnostic.code == "abc.lyric.syllable_count")
            .count(),
        1
    );
}

#[test]
fn lyric_hyphen_across_non_adjacent_blocks_exports_begin_end() {
    let source = "X:1\nT:Block Hyphen\nM:1/4\nL:1/4\nK:C\nC|\nw: A-\nD|\nw: men\n";
    let export = export_musicxml(source).expect("block hyphen score should export");

    assert_balanced_xml(&export.musicxml);
    let syllabic = export
        .musicxml
        .match_indices("<syllabic>")
        .map(|(index, _)| {
            let start = index + "<syllabic>".len();
            let end = export.musicxml[start..]
                .find("</syllabic>")
                .map(|offset| start + offset)
                .expect("syllabic should close");
            &export.musicxml[start..end]
        })
        .collect::<Vec<_>>();
    assert_eq!(syllabic, vec!["begin", "end"]);
    assert!(export.musicxml.contains("<text>A</text>"));
    assert!(export.musicxml.contains("<text>men</text>"));
    assert!(export.diagnostics.is_empty());
}

#[test]
fn lyric_syllabic_state_follows_source_voice_across_overlays() {
    let source = "X:1\nT:Overlay Lyrics\nM:3/4\nL:1/4\nK:C\nC & E F|\nw: A-men all\n";
    let export = export_musicxml(source).expect("overlay lyric score should export");

    assert_balanced_xml(&export.musicxml);
    let syllabic = export
        .musicxml
        .match_indices("<syllabic>")
        .map(|(index, _)| {
            let start = index + "<syllabic>".len();
            let end = export.musicxml[start..]
                .find("</syllabic>")
                .map(|offset| start + offset)
                .expect("syllabic should close");
            &export.musicxml[start..end]
        })
        .collect::<Vec<_>>();

    assert_eq!(syllabic, vec!["begin", "end", "single"]);
    assert!(export.musicxml.contains("<text>A</text>"));
    assert!(export.musicxml.contains("<text>men</text>"));
    assert!(export.musicxml.contains("<text>all</text>"));
}

#[test]
fn escaped_literal_hyphen_in_lyrics_still_exports_as_text() {
    let source = "X:1\nT:Literal Hyphen Lyrics\nM:2/4\nL:1/4\nK:C\nC D|\nw: \\-dash end\n";
    let export = export_musicxml(source).expect("literal hyphen lyric score should export");

    assert_balanced_xml(&export.musicxml);
    assert!(export.musicxml.contains("<text>-dash</text>"));
    assert!(!export.musicxml.contains("<text>-</text>"));
    assert!(export.diagnostics.is_empty());
}

#[test]
fn lyric_underscore_exports_melisma_extender_without_sung_text() {
    let source = "X:1\nT:Melisma Lyrics\nM:3/4\nL:1/4\nK:C\nC D E|\nw: time_ day\n";
    let export = export_musicxml(source).expect("melisma lyric score should export");

    assert_balanced_xml(&export.musicxml);
    assert!(export.musicxml.contains("<text>time</text>"));
    assert!(export.musicxml.contains("<extend/>"));
    assert!(export.musicxml.contains("<text>day</text>"));
    assert!(!export.musicxml.contains("<text>_</text>"));
    assert!(export.diagnostics.is_empty());
}

#[test]
fn lyric_extend_carrier_before_chord_attaches_to_chord_head() {
    // The reader preserves a MusicXML same-note <text>+<extend/> melisma as the
    // inline `[I:croma-lyric-extend ...]` carrier. Flushed before a CHORD it must
    // attach to the chord head, like the single-note path — `push_chord_group`
    // used to drop it, so the held syllable lost its <extend/> on re-export and
    // the hold shifted onto a later note (PDMX lyric cluster).
    let source = "X:1\nL:1/8\nK:C\nC2 [I:croma-lyric-extend verse=1][FA]2 G2|\nw: a sing c\n";
    let export = export_musicxml(source).expect("lyric-extend before chord should export");

    assert_balanced_xml(&export.musicxml);
    // The held syllable 'sing' is on the chord head; its <extend/> must sit in the
    // same <note> (before the next <note>).
    let sing = export
        .musicxml
        .find("<text>sing</text>")
        .expect("sing syllable should be present");
    let rest = &export.musicxml[sing..];
    let next_note = rest.find("<note").unwrap_or(rest.len());
    assert!(
        rest[..next_note].contains("<extend/>"),
        "chord head 'sing' must keep its <extend/>: {}",
        &rest[..next_note.min(160)]
    );
}

#[test]
fn lyric_duplicate_carrier_before_chord_attaches_to_chord_head() {
    // The reader preserves a second same-verse <lyric> on one note (foreign
    // MusicXML stamps credit lines repeatedly) as the inline
    // `[I:croma-lyric-duplicate ...]` carrier. Flushed before a CHORD it must
    // attach to the chord head; `push_chord_group` used to drop it onto a later
    // note (PDMX duplicate-credit cluster, ~35 files).
    let source = "X:1\nL:1/8\nK:C\n[I:croma-lyric-duplicate verse=1 text=By][DF]2 G2|\nw: By c\n";
    let export = export_musicxml(source).expect("lyric-duplicate before chord should export");

    assert_balanced_xml(&export.musicxml);
    // The chord head (the first <note>) must carry BOTH number=1 'By' lyrics.
    let first_note = export
        .musicxml
        .find("<note")
        .expect("a note should be present");
    let after_first = first_note + "<note".len();
    let second_note = export.musicxml[after_first..]
        .find("<note")
        .map(|offset| after_first + offset)
        .expect("a second note should be present");
    let head = &export.musicxml[first_note..second_note];
    assert_eq!(
        count(head, "<text>By</text>"),
        2,
        "chord head must carry both duplicate 'By' lyrics: {head}"
    );
}

#[test]
fn clef_octave_suffix_shifts_notes_and_marks_the_clef() {
    // `clef=treble-8` writes the notes one octave lower and adds a matching
    // clef-octave-change, like abc2xml. `C` (octave 4) becomes octave 3.
    let source = "X:1\nL:1/4\nK:C\nV:1 clef=treble-8\nC2 C2|\n";
    let export = export_musicxml(source).expect("treble-8 score should export");
    assert_balanced_xml(&export.musicxml);
    assert!(
        export
            .musicxml
            .contains("<clef-octave-change>-1</clef-octave-change>")
    );
    assert!(export.musicxml.contains("<octave>3</octave>"));
    assert!(!export.musicxml.contains("<octave>4</octave>"));
}

#[test]
fn voice_middle_modifier_shifts_octave() {
    // `clef=bass middle=d` declares d on the middle staff line, which shifts the
    // written->sounding octave down by two (abc2xml gtrans = -2). A lowercase
    // `e` (octave 5 written) therefore sounds in octave 3.
    let source = "X:1\nT:Bass test\nM:C\nL:1/4\nK:C\nV:1 clef=bass middle=d\ne e e e |\n";
    let export = export_musicxml(source).expect("bass middle=d score should export");
    assert_balanced_xml(&export.musicxml);
    assert!(export.musicxml.contains("<octave>3</octave>"));
    assert!(!export.musicxml.contains("<octave>5</octave>"));
}

#[test]
fn bare_voice_switch_keeps_each_header_clef() {
    // Header voice definitions carry clefs; a later bare `V:n` switch in the
    // body must not wipe them. Each voice keeps its own clef and octave.
    let source = concat!(
        "X:1\nL:1/4\n",
        "V:1 clef=treble\nV:2 clef=bass\nK:C\n",
        "V:1\nc c|\n",
        "V:2\nC, C,|\n",
    );
    let export = export_musicxml(source).expect("multi-voice score should export");
    assert_balanced_xml(&export.musicxml);
    let p2 = export
        .musicxml
        .split("<part id=\"P2\">")
        .nth(1)
        .expect("part P2");
    assert!(
        p2.contains("<sign>F</sign>"),
        "V:2 should keep its bass clef"
    );
}

#[test]
fn key_field_octave_property_shifts_single_voice() {
    // `octave=1` on a header `K:` line (ABC 2.1 §4.6 allows clef/octave on K:)
    // shifts every note up one octave, exactly like abc2xml. The baseline (no
    // `octave=`) writes `C` at octave 4; with `octave=1` it must be octave 5.
    let baseline = export_musicxml("X:1\nL:1/4\nK:C\nC2 C2|\n").expect("baseline should export");
    assert!(baseline.musicxml.contains("<octave>4</octave>"));
    assert!(!baseline.musicxml.contains("<octave>5</octave>"));

    let shifted =
        export_musicxml("X:1\nL:1/4\nK:C octave=1\nC2 C2|\n").expect("octave=1 should export");
    assert_balanced_xml(&shifted.musicxml);
    assert!(
        shifted.musicxml.contains("<octave>5</octave>"),
        "octave=1 on K: should shift C from octave 4 to 5"
    );
    assert!(!shifted.musicxml.contains("<octave>4</octave>"));
}

#[test]
fn oversized_octave_and_clef_shifts_clamp_instead_of_panicking() {
    // `V:1 clef=treble+15 octave=125` used to overflow the per-note i8
    // base+shift addition (debug panic). The shift now clamps: the +15
    // suffix gives +2, `octave=125` clamps to +9 (abc2xml's effective
    // single-digit domain), total +11, so C (octave 4) lands at octave 15.
    let export = export_musicxml("X:1\nL:1/4\nK:C\nV:1 clef=treble+15 octave=125\nC2 C2|\n")
        .expect("oversized voice shifts should export");
    assert_balanced_xml(&export.musicxml);
    assert!(
        export.musicxml.contains("<octave>15</octave>"),
        "clef +15 (+2) and octave=125 (clamped +9) should shift C to octave 15"
    );

    // `octave=99999` (outside i8, previously silently ignored) clamps to +9,
    // also via the `K:` clef-property merge path: C octave 4 -> 13.
    let export = export_musicxml("X:1\nL:1/4\nK:C octave=99999\nC2 C2|\n")
        .expect("oversized K: octave should export");
    assert_balanced_xml(&export.musicxml);
    assert!(
        export.musicxml.contains("<octave>13</octave>"),
        "octave=99999 on K: should clamp to +9 and shift C to octave 13"
    );

    // A long octave-mark run no longer overflows the i8 mark sum.
    let marks = ",".repeat(200);
    let source = format!("X:1\nL:1/4\nK:C\nC{marks}DEF|\n");
    let export = export_musicxml(&source).expect("long octave-mark run should export");
    assert_balanced_xml(&export.musicxml);
}

#[test]
fn key_field_clef_shorthand_scopes_to_current_voice_only() {
    // `K:C treble+8` / `K:C treble-8` appearing after a `V:n` switch scope to the
    // voice currently in scope only (abc2xml semantics), and must NOT leak into
    // other voices. V:1 gets +8 (C octave 4 -> 5); V:2 gets -8 (C octave 4 -> 3).
    let source = concat!(
        "X:1\nL:1/4\n",
        "K:C\n",
        "V:1\n",
        "K:C treble+8\n",
        "C2 C2|\n",
        "V:2\n",
        "K:C treble-8\n",
        "C2 C2|\n",
    );
    let export = export_musicxml(source).expect("multi-voice K: clef score should export");
    assert_balanced_xml(&export.musicxml);

    let p1 = export
        .musicxml
        .split("<part id=\"P2\">")
        .next()
        .expect("part P1 segment");
    let p2 = export
        .musicxml
        .split("<part id=\"P2\">")
        .nth(1)
        .expect("part P2 segment");

    // V:1 shifted up: octave 5, never octave 4 or 3.
    assert!(
        p1.contains("<octave>5</octave>"),
        "V:1 K:C treble+8 should shift C to octave 5"
    );
    assert!(
        !p1.contains("<octave>3</octave>"),
        "V:1 shift must not be the V:2 (-8) shift"
    );
    // V:2 shifted down: octave 3, and crucially NOT octave 5 (no leak from V:1).
    assert!(
        p2.contains("<octave>3</octave>"),
        "V:2 K:C treble-8 should shift C to octave 3"
    );
    assert!(
        !p2.contains("<octave>5</octave>"),
        "V:1's +8 shift must not leak into V:2"
    );
}

#[test]
fn unclosed_decoration_does_not_swallow_following_notes() {
    // `!f2e2f2` is a stray `!` before notes (a deprecated line-break or
    // typo), not a decoration named "f2e2f2". The notes must survive.
    let source = "X:1\nL:1/8\nK:C\nA !f2e2f2 | g2|\n";
    let export = export_musicxml(source).expect("stray-bang score should export");
    assert_balanced_xml(&export.musicxml);
    assert_eq!(count(&export.musicxml, "<step>F</step>"), 2);
    assert_eq!(count(&export.musicxml, "<step>E</step>"), 1);
    assert_eq!(count(&export.musicxml, "<step>A</step>"), 1);
    assert_eq!(count(&export.musicxml, "<step>G</step>"), 1);
}

#[test]
fn chords_adjacent_to_barlines_are_not_swallowed() {
    // `|[G2C,2]` and `][` must keep the chords intact: the `[` opens a chord,
    // it is not part of a liberal `|[` / `][` barline. Two 4/4 measures of
    // four quarter chords each, not many tiny measures.
    let source =
        "X:1\nL:1/8\nM:4/4\nK:C\n[G2C,2][c2C,2][A2F,2][e2C,2]|[F2D,2][D2D,2][A2D,2][d2D,2]|\n";
    let export = export_musicxml(source).expect("bass-chord score should export");
    assert_balanced_xml(&export.musicxml);
    assert_eq!(count(&export.musicxml, "<measure "), 2);
    assert!(
        !export
            .diagnostics
            .iter()
            .any(|diagnostic| diagnostic.code == "abc.music.barline.liberal"),
        "no chord bracket should be read as a liberal barline"
    );
}

#[test]
fn staccato_chord_keeps_its_length_suffix() {
    // `.[CE]2` is a staccato chord of length 2 (a quarter at L:1/8), not a
    // dotted barline. The leading `.` must not swallow the chord's length.
    let source = "X:1\nL:1/8\nK:C\n.[CE]2 [CE]|\n";
    let export = export_musicxml(source).expect("staccato chord should export");
    assert_balanced_xml(&export.musicxml);
    assert!(export.musicxml.contains("<staccato/>"));
    // Two chords (each two notes) in one measure: the first is a quarter.
    assert_eq!(count(&export.musicxml, "<chord/>"), 2);
    assert_eq!(count(&export.musicxml, "<type>quarter</type>"), 2);
    assert_eq!(count(&export.musicxml, "<type>eighth</type>"), 2);
}

#[test]
fn decorations_map_to_notation_elements_not_words() {
    // ABC decorations map to MusicXML notation categories, not <words>:
    // fermata -> <fermata>, staccato/accent -> <articulations>,
    // up-bow -> <technical>, trill -> <ornaments>.
    let source = "X:1\nL:1/4\nK:C\n!fermata!.C !accent!D|!upbow!E !trill!F|\n";
    let export = export_musicxml(source).expect("decorated score should export");
    assert_balanced_xml(&export.musicxml);
    assert!(export.musicxml.contains("<fermata type=\"upright\"/>"));
    assert!(export.musicxml.contains("<articulations>"));
    assert!(export.musicxml.contains("<staccato/>"));
    assert!(export.musicxml.contains("<accent/>"));
    assert!(export.musicxml.contains("<technical>"));
    assert!(export.musicxml.contains("<up-bow/>"));
    assert!(export.musicxml.contains("<ornaments>"));
    assert!(export.musicxml.contains("<trill-mark/>"));
    assert!(!export.musicxml.contains("<words>fermata</words>"));
    assert!(!export.musicxml.contains("<words>accent</words>"));
}

#[test]
fn shorthand_decorations_map_to_notation_elements_not_words() {
    // ABC 2.1 §4.14 single-char shorthand decorations are the canonical
    // equivalents of the long-form `!...!` names and must map to the same
    // MusicXML notation/symbol output, never to <words> directions.
    let source = "X:1\nL:1/4\nK:C\nHC TD|uE vF|MG Pa|\n";
    let export = export_musicxml(source).expect("shorthand decorations should export");
    assert_balanced_xml(&export.musicxml);
    // H -> fermata
    assert!(export.musicxml.contains("<fermata type=\"upright\"/>"));
    // T -> trill
    assert!(export.musicxml.contains("<trill-mark/>"));
    // u -> up-bow, v -> down-bow
    assert!(export.musicxml.contains("<up-bow/>"));
    assert!(export.musicxml.contains("<down-bow/>"));
    // M -> lowermordent (mordent), P -> uppermordent (inverted-mordent)
    assert!(export.musicxml.contains("<mordent/>"));
    assert!(export.musicxml.contains("<inverted-mordent/>"));
    // No raw shorthand chars leak out as <words>.
    for raw in ["H", "T", "u", "v", "M", "P"] {
        assert!(
            !export.musicxml.contains(&format!("<words>{raw}</words>")),
            "shorthand `{raw}` should not be emitted as <words>"
        );
    }
}

#[test]
fn shorthand_accent_maps_to_articulation_not_words() {
    let source = "X:1\nL:1/4\nK:C\nLC D|\n";
    let export = export_musicxml(source).expect("shorthand accent should export");
    assert_balanced_xml(&export.musicxml);
    assert!(export.musicxml.contains("<articulations>"));
    assert!(export.musicxml.contains("<accent/>"));
    assert!(!export.musicxml.contains("<words>L</words>"));
}

#[test]
fn shorthand_segno_and_coda_map_to_direction_symbols_not_words() {
    let source = "X:1\nL:1/4\nK:C\nSC OD|\n";
    let export = export_musicxml(source).expect("shorthand segno/coda should export");
    assert_balanced_xml(&export.musicxml);
    assert!(export.musicxml.contains("<segno/>"));
    assert!(export.musicxml.contains("<coda/>"));
    assert!(!export.musicxml.contains("<words>S</words>"));
    assert!(!export.musicxml.contains("<words>O</words>"));
}

#[test]
fn shorthand_roll_emits_neither_words_nor_diagnostic() {
    // `~` (Irish roll / general gracing) has no clean MusicXML equivalent;
    // abc2xml emits nothing. The hard requirement is that it must NOT become
    // a <words> direction, which would show up as an extra music21 direction.
    let source = "X:1\nL:1/4\nK:C\n~C D|\n";
    let export = export_musicxml(source).expect("shorthand roll should export");
    assert_balanced_xml(&export.musicxml);
    assert!(!export.musicxml.contains("<words>~</words>"));
    assert!(!export.musicxml.contains("<words>roll</words>"));
    // Suppressed cleanly: no unsupported-decoration diagnostic for `~`.
    assert!(
        !export
            .diagnostics
            .iter()
            .any(|diagnostic| diagnostic.code == "abc.musicxml.decoration.unsupported"),
        "roll should be suppressed without an unsupported-decoration diagnostic"
    );
    // The notes and their timing survive.
    assert_eq!(count(&export.musicxml, "<note>"), 2);
}

#[test]
fn user_defined_symbol_expands_to_its_notation_not_words() {
    // `U:T=!trill!` redefines T; a `U:`-defined letter must expand to its
    // definition and map through the same notation path.
    let source = "X:1\nU:W=!trill!\nL:1/4\nK:C\nWC D|\n";
    let export = export_musicxml(source).expect("user symbol should export");
    assert_balanced_xml(&export.musicxml);
    assert!(export.musicxml.contains("<trill-mark/>"));
    assert!(!export.musicxml.contains("<words>W</words>"));
}

#[test]
fn bare_user_defined_symbol_name_expands_to_its_notation_not_words() {
    let source = "X:1\nU:t=tenuto\nL:1/4\nK:C\ntC D|\n";
    let export = export_musicxml(source).expect("user symbol should export");
    assert_balanced_xml(&export.musicxml);
    assert!(export.musicxml.contains("<tenuto/>"));
    assert!(!export.musicxml.contains("<words>t</words>"));
    assert!(
        !export
            .diagnostics
            .iter()
            .any(|diagnostic| diagnostic.code == "abc.musicxml.decoration.unsupported")
    );
}

#[test]
fn post_tune_words_export_as_credits_not_in_measure_directions() {
    // `W:` words are printed after the tune (ABC 2.1), so they belong in
    // score-header <credit> elements, not as in-measure <words> directions.
    let source = "X:1\nT:Song\nL:1/4\nK:C\nC D E F|\nW: Verse one here\nW: Verse two here\nW:\n";
    let export = export_musicxml(source).expect("post-tune words should export");
    assert_balanced_xml(&export.musicxml);
    assert!(
        export
            .musicxml
            .contains("<credit-words>Verse one here</credit-words>")
    );
    assert!(
        export
            .musicxml
            .contains("<credit-words>Verse two here</credit-words>")
    );
    // The empty `W:` line is skipped, and no verse leaks into a direction.
    assert!(!export.musicxml.contains("<words>Verse one here</words>"));
    assert_eq!(count(&export.musicxml, "<credit-words>"), 2);
}

#[test]
fn staves_parenthesis_group_merges_voices_into_one_part() {
    // `%%staves 1 (2 3) 4`: voices 2 and 3 share one part; 1 and 4 are their
    // own parts, giving three parts.
    let source = concat!(
        "X:1\nL:1/4\n%%staves 1 (2 3) 4\n",
        "V:1\nV:2\nV:3\nV:4\nK:C\n",
        "V:1\nC D|\nV:2\nE F|\nV:3\nG A|\nV:4\nc d|\n",
    );
    let export = export_musicxml(source).expect("grouped score should export");
    assert_balanced_xml(&export.musicxml);
    assert_eq!(count(&export.musicxml, "<part id="), 3);
}

#[test]
fn reader_style_hash_voice_ids_preserve_musicxml_voice_numbers() {
    let source = concat!(
        "X:1\nL:1/4\n%%score (P1 P1#5)\n",
        "V:P1\nV:P1#5\nK:C\n",
        "V:P1\nC |\n",
        "V:P1#5\nG |\n",
    );
    let export = export_musicxml(source).expect("reader-style voice ids should export");

    assert_balanced_xml(&export.musicxml);
    assert_eq!(count(&export.musicxml, "<part id="), 1);
    assert!(
        export.musicxml.contains("<voice>5</voice>"),
        "reader-style voice id P1#5 must emit MusicXML voice 5, not voice 2:\n{}",
        export.musicxml
    );
    assert!(
        !export.musicxml.contains("<voice>2</voice>"),
        "reader-style voice id P1#5 must not be normalized to voice 2:\n{}",
        export.musicxml
    );
}

#[test]
fn slur_number_collision_shared_part_uses_distinct_musicxml_numbers() {
    let source = concat!(
        "X:1\nM:4/4\nL:1/4\n%%staves 1 (2 3)\n",
        "V:1\nV:2\nV:3\nK:C\n",
        "V:1\nz z z z|z z z z|]\n",
        "V:2\n(e e e e|e) z z z|]\n",
        "V:3\n(c c c c|c) z z z|]\n",
    );
    let export = export_musicxml(source).expect("grouped slurs should export");

    assert_balanced_xml(&export.musicxml);
    let parts = musicxml_part_blocks(&export.musicxml);
    assert_eq!(
        parts.len(),
        2,
        "expected one solo part and one grouped part"
    );
    let shared_part = parts
        .iter()
        .find(|part| count(part, "<slur ") == 4)
        .expect("grouped part should contain both slur pairs");
    assert!(shared_part.contains("<voice>1</voice>"));
    assert!(shared_part.contains("<voice>2</voice>"));

    let slur_events = musicxml_note_blocks(shared_part)
        .into_iter()
        .filter_map(|note| {
            let slur_start = note.find("<slur ")?;
            let slur_end = note[slur_start..]
                .find("/>")
                .map(|end| slur_start + end + 2)
                .expect("slur tag should close");
            let slur = &note[slur_start..slur_end];
            Some((
                element_text(note, "voice").expect("slurred note should have voice"),
                element_text(note, "step")
                    .and_then(|step| step.chars().next())
                    .expect("slurred note should have pitch step"),
                attr_value(slur, "type").expect("slur should have type"),
                attr_value(slur, "number").expect("slur should have number"),
            ))
        })
        .collect::<Vec<_>>();

    assert_eq!(
        slur_events,
        vec![
            ("1".to_owned(), 'E', "start".to_owned(), "1".to_owned()),
            ("2".to_owned(), 'C', "start".to_owned(), "2".to_owned()),
            ("1".to_owned(), 'E', "stop".to_owned(), "1".to_owned()),
            ("2".to_owned(), 'C', "stop".to_owned(), "2".to_owned()),
        ]
    );
}

#[test]
fn slur_number_collision_overlay_stop_uses_overlay_start_number() {
    let source = concat!(
        "X:1\nM:4/4\nL:1/4\n%%staves (1 2)\n",
        "V:1\nV:2\nK:C\n",
        "V:1\n(a a a a & (e e e e|e) a a a)|]\n",
        "V:2\nz z z z|z z z z|]\n",
    );
    let export = export_musicxml(source).expect("overlay slurs should export");

    assert_balanced_xml(&export.musicxml);
    let slur_events = musicxml_note_blocks(&export.musicxml)
        .into_iter()
        .flat_map(slur_events_in_note)
        .collect::<Vec<_>>();

    assert_eq!(
        slur_events,
        vec![
            SlurEvent::new("1", 'A', "start", "1"),
            SlurEvent::new("3", 'E', "start", "2"),
            SlurEvent::new("1", 'E', "stop", "2"),
            SlurEvent::new("1", 'A', "stop", "1"),
        ]
    );
}

#[test]
fn grouped_voice_overlays_use_part_unique_voice_numbers() {
    let source = concat!(
        "X:1\nM:4/4\nL:1/4\n%%staves (1 2)\n",
        "V:1\nV:2\nK:C\n",
        "V:1\nC D & E F|\n",
        "V:2\nG A & B c|\n",
    );
    let export = export_musicxml(source).expect("grouped overlays should export");

    assert_balanced_xml(&export.musicxml);
    let parts = musicxml_part_blocks(&export.musicxml);
    assert_eq!(parts.len(), 1, "voices should share one grouped part");
    let part = parts[0];
    for voice in ["1", "2", "3", "4"] {
        assert!(
            part.contains(&format!("<voice>{voice}</voice>")),
            "expected grouped part to contain voice {voice}: {part}"
        );
    }
    let voice_sequence = musicxml_note_blocks(part)
        .into_iter()
        .map(|note| element_text(note, "voice").expect("note should have voice"))
        .collect::<Vec<_>>();
    assert_eq!(voice_sequence, vec!["1", "1", "3", "3", "2", "2", "4", "4"]);
}

#[test]
fn grouped_voice_overlay_numbers_are_stable_across_measures() {
    let source = concat!(
        "X:1\nM:2/4\nL:1/4\n%%staves (1 2)\n",
        "V:1\nV:2\nK:C\n",
        "V:1\nC & E|D F|\n",
        "V:2\nG A|B & c|\n",
    );
    let export = export_musicxml(source).expect("staggered overlays should export");

    assert_balanced_xml(&export.musicxml);
    let part = musicxml_part_blocks(&export.musicxml)
        .into_iter()
        .next()
        .expect("grouped part should be present");
    let voice_for_pitch = |step: char, octave: &str| {
        musicxml_note_blocks(part)
            .into_iter()
            .find(|note| {
                element_text(note, "step").is_some_and(|text| text.starts_with(step))
                    && element_text(note, "octave").is_some_and(|text| text == octave)
            })
            .and_then(|note| element_text(note, "voice"))
            .expect("pitch should have a voice")
    };

    assert_eq!(voice_for_pitch('E', "4"), "3");
    assert_eq!(voice_for_pitch('C', "5"), "4");
}

#[test]
fn slur_number_allocator_preserves_high_pair_ids_and_remaps_collisions() {
    let mut slur_numbers = SlurNumbers::default();

    assert_eq!(
        slur_numbers.number_for("voice-a", 17, crate::model::SlurRole::Start),
        17
    );
    assert_eq!(
        slur_numbers.number_for("voice-a", 17, crate::model::SlurRole::Stop),
        17
    );

    assert_eq!(
        slur_numbers.number_for("voice-a", 1, crate::model::SlurRole::Start),
        1
    );
    assert_eq!(
        slur_numbers.number_for("voice-b", 1, crate::model::SlurRole::Start),
        2
    );
    assert_eq!(
        slur_numbers.number_for("voice-a", 1, crate::model::SlurRole::Stop),
        1
    );
    assert_eq!(
        slur_numbers.number_for("voice-b", 1, crate::model::SlurRole::Stop),
        2
    );
    assert_eq!(
        slur_numbers.number_for("voice-c", 1, crate::model::SlurRole::Start),
        1
    );
}

#[test]
fn staves_bracket_group_keeps_one_part_per_voice() {
    let source = concat!(
        "X:1\nL:1/4\n%%staves [1 2 3]\n",
        "V:1\nV:2\nV:3\nK:C\n",
        "V:1\nC D|\nV:2\nE F|\nV:3\nG A|\n",
    );
    let export = export_musicxml(source).expect("bracketed score should export");
    assert_eq!(count(&export.musicxml, "<part id="), 3);
}

#[test]
fn staves_bracket_group_orders_parts_by_directive() {
    let source = concat!(
        "X:1\nL:1/4\n%%staves [1 2 3]\n",
        "V:1\nV:3\nV:2\nK:C\n",
        "V:1\nC|\nV:2\nE|\nV:3\nG|\n",
    );
    let export = export_musicxml(source).expect("bracketed score should export");
    let first_steps = musicxml_part_blocks(&export.musicxml)
        .into_iter()
        .map(|part| {
            let first_note = musicxml_note_blocks(part)
                .into_iter()
                .find(|note| note.contains("<pitch>"))
                .expect("part should contain a pitched note");
            element_text(first_note, "step").expect("pitched note should have a step")
        })
        .collect::<Vec<_>>();

    assert_eq!(first_steps, vec!["C", "E", "G"]);
}

#[test]
fn each_voice_becomes_its_own_part() {
    // A multi-voice tune exports as one score with one <part> per voice, in
    // voice order, all in a single document (matching abc2xml/music21).
    let source = "X:1\nL:1/4\nK:C\nV:1\nC D|E F|\nV:2\nG A|B c|\nV:3\nc B|A G|\n";
    let export = export_musicxml(source).expect("multi-voice score should export");
    assert_balanced_xml(&export.musicxml);
    assert_eq!(count(&export.musicxml, "<score-partwise"), 1);
    assert_eq!(count(&export.musicxml, "<part id="), 3);
    for id in ["P1", "P2", "P3"] {
        assert!(
            export.musicxml.contains(&format!("<part id=\"{id}\"")),
            "missing part {id}"
        );
    }
}

#[test]
fn single_voice_tune_stays_one_part() {
    let source = "X:1\nL:1/4\nK:C\nC D E F|\n";
    let export = export_musicxml(source).expect("single-voice score should export");
    assert_eq!(count(&export.musicxml, "<part id="), 1);
}

#[test]
fn inline_key_change_applies_to_following_accidentals() {
    // `[K:D]` mid-tune must make the following notes use the D-major key
    // signature: the C in the second measure becomes C-sharp.
    let source = "X:1\nL:1/8\nK:C\nCEG c|[K:D]CEG c|\n";
    let export = export_musicxml(source).expect("inline key change should export");
    assert_balanced_xml(&export.musicxml);
    let second = export
        .musicxml
        .split("<measure number=\"2\">")
        .nth(1)
        .expect("second measure");
    assert!(
        second.contains("<step>C</step>\n          <alter>1</alter>"),
        "second measure C should be sharp under inline K:D"
    );
}

#[test]
fn tonic_less_key_accidental_modifies_prevailing_key() {
    // tune_005044: a body `K:^F` after a `K:Gmin` header is a tonic-less key
    // modification (ABC 2.1 §3.1.14 "key signatures may be modified by adding
    // accidentals"). It must INHERIT the prevailing G-minor (Bb/Eb) and ADD F#
    // — like the tonic-ful `K:Gm ^f` — not be dropped as invalid. So the
    // following bare F sounds F#, AND the prevailing two-flat signature stays
    // (B stays flat, not reset to an F#-only key).
    let source = "X:1\nL:1/8\nK:Gmin\nGABc|\nK:^F\nFBFB|\n";
    let export = export_musicxml(source).expect("tonic-less key accidental should export");
    assert_balanced_xml(&export.musicxml);
    let second = export
        .musicxml
        .split("<measure number=\"2\">")
        .nth(1)
        .expect("second measure");
    assert!(
        second.contains("<step>F</step>\n          <alter>1</alter>"),
        "F must sound F# after `K:^F` modifies the prevailing G-minor: {second}"
    );
    assert!(
        second.contains("<step>B</step>\n          <alter>-1</alter>"),
        "B must stay flat (prevailing G-minor inherited, not reset to F#-only): {second}"
    );
}

#[test]
fn inline_clef_only_key_field_does_not_reset_the_signature() {
    // `[K:clef=bass]` only changes the clef; it must not be misread as a key
    // change that wipes the D-major signature (the following F stays F#).
    let source = "X:1\nL:1/8\nK:D\nFAd f|[K:clef=bass]FAd f|\n";
    let export = export_musicxml(source).expect("inline clef change should export");
    assert_balanced_xml(&export.musicxml);
    let second = export
        .musicxml
        .split("<measure number=\"2\">")
        .nth(1)
        .expect("second measure");
    assert!(
        second.contains("<step>F</step>\n          <alter>1</alter>"),
        "F should stay sharp; clef-only inline key must not reset the signature"
    );
}

#[test]
fn inline_clef_shorthand_key_field_emits_mid_tune_clef_and_shifts_notes() {
    let source = "X:1\nL:1/4\nK:C\nC C|[K:treble-8]C C|[K:treble+8]C C|\n";
    let export = export_musicxml(source).expect("inline clef shorthand should export");
    assert_balanced_xml(&export.musicxml);

    let first = export
        .musicxml
        .split("<measure number=\"2\">")
        .next()
        .expect("first measure");
    let second = export
        .musicxml
        .split("<measure number=\"2\">")
        .nth(1)
        .and_then(|tail| tail.split("<measure number=\"3\">").next())
        .expect("second measure");
    let third = export
        .musicxml
        .split("<measure number=\"3\">")
        .nth(1)
        .expect("third measure");

    assert!(
        !first.contains("<clef-octave-change>"),
        "the header clef must stay unshifted"
    );
    assert!(
        second.contains("<clef-octave-change>-1</clef-octave-change>"),
        "treble-8 should emit a mid-tune clef octave change"
    );
    assert!(
        second.contains("<octave>3</octave>"),
        "treble-8 should shift following C notes down to octave 3"
    );
    assert!(
        third.contains("<clef-octave-change>1</clef-octave-change>"),
        "treble+8 should emit a mid-tune clef octave change"
    );
    assert!(
        third.contains("<octave>5</octave>"),
        "treble+8 should shift following C notes up to octave 5"
    );
}

#[test]
fn nospace_key_global_accidentals_export_base_key() {
    let export = export_musicxml("X:1\nL:1/4\nK:D_B^g\nF B G|\n").expect("score should export");
    assert_balanced_xml(&export.musicxml);

    assert!(export.musicxml.contains("<fifths>2</fifths>"));
    assert_eq!(count(&export.musicxml, "<key-step>"), 0);
    assert_eq!(count(&export.musicxml, "<key-accidental>"), 0);
}

#[test]
fn escaped_literal_underscore_in_lyrics_still_exports_as_text() {
    let source = "X:1\nT:Literal Underscore Lyrics\nM:2/4\nL:1/4\nK:C\nC D|\nw: \\_hold end\n";
    let export = export_musicxml(source).expect("literal underscore lyric score should export");

    assert_balanced_xml(&export.musicxml);
    assert!(export.musicxml.contains("<text>_hold</text>"));
    assert!(!export.musicxml.contains("<text>_</text>"));
    assert!(export.diagnostics.is_empty());
}

#[test]
fn lyric_nbsp_inside_tune_000509_style_word_is_not_a_separator() {
    let source = "X:1\nT:NBSP Melisma Lyrics\nM:6/4\nL:1/4\nK:C\nC D E F G A|\nw: A-ten-toÃ\u{00a0}a-do_ra\n";
    let export = export_musicxml(source).expect("NBSP lyric score should export");

    assert_balanced_xml(&export.musicxml);
    assert!(export.musicxml.contains("<text>toÃ\u{00a0}a</text>"));
    assert!(export.musicxml.contains("<text>do</text>"));
    assert!(export.musicxml.contains("<extend/>"));
    assert!(!export.musicxml.contains("<text>toÃ</text>"));
    assert!(export.diagnostics.is_empty());
}

#[test]
fn ties_across_barlines_export_start_and_stop_without_diagnostic() {
    let source = "X:1\nM:2/4\nL:1/4\nK:C\nC- | C D |\n";
    let export = export_musicxml(source).expect("cross-bar tie should export");

    assert_balanced_xml(&export.musicxml);
    assert!(
        !export
            .diagnostics
            .iter()
            .any(|diagnostic| diagnostic.code == "abc.music.unmatched_tie")
    );
    assert_eq!(count(&export.musicxml, "<tie type=\"start\"/>"), 1);
    assert_eq!(count(&export.musicxml, "<tie type=\"stop\"/>"), 1);
    assert_eq!(count(&export.musicxml, "<tied type=\"start\""), 1);
    assert_eq!(count(&export.musicxml, "<tied type=\"stop\""), 1);
    let measures = musicxml_measures(&export.musicxml);
    assert_eq!(measure_numbers(&measures), vec!["1", "2"]);
    assert_eq!(measures[0].notes.len(), 1);
    assert_eq!(measures[1].notes.len(), 2);
}

#[test]
fn whole_chord_tie_ties_every_matching_member() {
    // `[CE]-[CE]` ties both chord members across the two chords.
    let source = "X:1\nM:4/4\nL:1/2\nK:C\n[CE]-[CE]|\n";
    let export = export_musicxml(source).expect("whole-chord tie should export");
    assert_balanced_xml(&export.musicxml);
    assert!(
        !export
            .diagnostics
            .iter()
            .any(|diagnostic| diagnostic.code == "abc.music.unmatched_tie")
    );
    // Both members tie: two starts + two stops for each of <tie> and <tied>.
    assert_eq!(count(&export.musicxml, "<tie type=\"start\"/>"), 2);
    assert_eq!(count(&export.musicxml, "<tie type=\"stop\"/>"), 2);
    assert_eq!(count(&export.musicxml, "<tied type=\"start\""), 2);
    assert_eq!(count(&export.musicxml, "<tied type=\"stop\""), 2);
}

#[test]
fn chord_internal_tie_ties_only_the_marked_member() {
    // `[DA-]2[FA]`: A continues into the next chord, D does not.
    let source = "X:1\nM:4/4\nL:1/4\nK:C\n[DA-]2[FA]|\n";
    let export = export_musicxml(source).expect("chord-internal tie should export");
    assert_balanced_xml(&export.musicxml);
    // Exactly one tied pair (the A member); D is not tied.
    assert_eq!(count(&export.musicxml, "<tie type=\"start\"/>"), 1);
    assert_eq!(count(&export.musicxml, "<tie type=\"stop\"/>"), 1);
    assert_eq!(count(&export.musicxml, "<tied type=\"start\""), 1);
    assert_eq!(count(&export.musicxml, "<tied type=\"stop\""), 1);
}

#[test]
fn standalone_tie_remains_a_single_pair_and_untied_chord_has_no_tied() {
    // Regression guard: plain single-note tie keeps exactly one pair.
    let single =
        export_musicxml("X:1\nM:4/4\nL:1/2\nK:C\nC2-C2|\n").expect("single tie should export");
    assert_eq!(count(&single.musicxml, "<tie type=\"start\"/>"), 1);
    assert_eq!(count(&single.musicxml, "<tie type=\"stop\"/>"), 1);
    assert_eq!(count(&single.musicxml, "<tied type=\"start\""), 1);
    assert_eq!(count(&single.musicxml, "<tied type=\"stop\""), 1);

    // A chord with no tie marker emits no <tied>.
    let chord =
        export_musicxml("X:1\nM:4/4\nL:1/1\nK:C\n[CE]|\n").expect("untied chord should export");
    assert!(!chord.musicxml.contains("<tied "));
    assert!(!chord.musicxml.contains("<tie "));
}

#[test]
fn grace_notes_export_reference_compatible_display_types_without_duration() {
    let source = "X:1\nT:Grace Display\nM:4/4\nL:1/4\nK:C\n{g}C {de}D|\n";
    let export = export_musicxml(source).expect("grace note should export");

    assert_balanced_xml(&export.musicxml);
    assert_eq!(count(&export.musicxml, "<grace/>"), 3);
    assert_eq!(count(&export.musicxml, "<type>eighth</type>"), 1);
    assert_eq!(count(&export.musicxml, "<type>16th</type>"), 2);
    assert!(!export.musicxml.contains("<duration>0</duration>"));
}

#[test]
fn grace_note_length_modifiers_scale_display_type() {
    // The graphic `<type>` of a grace note must reflect both the count-based
    // base unit and the grace note's written length modifier, matching
    // abc2xml: base 1/8 for a single grace, 1/16 for a group, then multiplied
    // by the note's written length.
    //   {B}      single, no modifier -> 1/8        -> eighth
    //   {B/}     single, half        -> 1/8 * 1/2  -> 16th
    //   {AG}     two graces          -> 1/16 each  -> 16th
    //   {A/G/}   two graces, half    -> 1/16 * 1/2 -> 32nd
    let cases = [
        ("{B}C", "<type>eighth</type>", 1),
        ("{B/}C", "<type>16th</type>", 1),
        ("{AG}C", "<type>16th</type>", 2),
        ("{A/G/}C", "<type>32nd</type>", 2),
    ];
    for (body, expected_type, expected_count) in cases {
        let source = format!("X:1\nT:Grace Length\nM:4/4\nL:1/4\nK:C\n{body}|\n");
        let export = export_musicxml(&source).expect("grace note should export");
        assert_balanced_xml(&export.musicxml);
        assert_eq!(
            count(&export.musicxml, expected_type),
            expected_count,
            "grace body {body} should yield {expected_count}x {expected_type}",
        );
        // Grace notes carry no <duration> element regardless of modifier.
        assert!(
            !export.musicxml.contains("<duration>0</duration>"),
            "grace body {body} must not emit a zero <duration>",
        );
    }
}

#[test]
fn grace_notes_apply_implicit_key_signature_alter() {
    let source = "X:1\nT:Grace Key\nM:4/4\nL:1/4\nK:D\n{f}A {=f}A|\n";
    let export = export_musicxml(source).expect("grace key accidental should export");

    assert_balanced_xml(&export.musicxml);
    assert_eq!(count(&export.musicxml, "<grace/>"), 2);
    assert_eq!(count(&export.musicxml, "<alter>1</alter>"), 1);
    assert!(export.musicxml.contains("<accidental>natural</accidental>"));
}

#[test]
fn grace_notes_export_inherited_measure_accidentals() {
    let source = "X:1\nL:1/8\nK:C\n^c2 {dc}d2 c4 |]\n";
    let export = export_musicxml(source).expect("grace accidental should export");

    assert_balanced_xml(&export.musicxml);
    assert_eq!(
        note_steps_and_alters(&export.musicxml),
        vec![('C', 1), ('D', 0), ('C', 1), ('D', 0), ('C', 1)]
    );
}

#[test]
fn sequential_tuplets_reuse_musicxml_number_levels() {
    let source = concat!(
        "X:1\n",
        "T:Many Tuplets\n",
        "M:4/4\n",
        "L:1/16\n",
        "K:C\n",
        "(3CDE (3DEF (3EFG (3FGA (3GAB (3ABc (3Bcd|\n",
    );
    let export = export_musicxml(source).expect("many sequential tuplets should export");

    assert_balanced_xml(&export.musicxml);
    assert_eq!(
        count(&export.musicxml, "<tuplet type=\"start\" number=\"1\"/>"),
        7
    );
    assert_eq!(
        count(&export.musicxml, "<tuplet type=\"stop\" number=\"1\"/>"),
        7
    );
    assert!(!export.musicxml.contains("number=\"7\""));
}

#[test]
fn one_note_tuplet_emits_balanced_start_and_stop() {
    let source = "X:1\nT:One Note Tuplet\nL:1/8\nK:C\n(3:2:1G B|\n";
    let export = export_musicxml(source).expect("one-note tuplet should export");

    assert_balanced_xml(&export.musicxml);
    assert_eq!(
        count(&export.musicxml, "<tuplet type=\"start\" number=\"1\"/>"),
        1
    );
    assert_eq!(
        count(&export.musicxml, "<tuplet type=\"stop\" number=\"1\"/>"),
        1
    );
    assert!(!export.musicxml.contains("number=\"2\""));
}

#[test]
fn nested_tuplets_emit_composite_time_modification_and_ordered_notations() {
    let source = concat!(
        "X:1\n",
        "T:Nested Tuplets\n",
        "M:C\n",
        "L:1/4\n",
        "K:C\n",
        "(7:8:8(3A/A/A/ A/A/A/A/A/|\n",
    );
    let export = export_musicxml(source).expect("nested tuplets should export");

    assert_balanced_xml(&export.musicxml);
    assert!(
        !export
            .diagnostics
            .iter()
            .any(|diagnostic| diagnostic.code == "abc.musicxml.duration.unsupported_note_type"),
        "diagnostics: {:?}",
        export.diagnostics
    );
    assert_eq!(
        count(&export.musicxml, "<tuplet type=\"start\" number=\"1\"/>"),
        1
    );
    assert_eq!(
        count(&export.musicxml, "<tuplet type=\"stop\" number=\"1\"/>"),
        1
    );
    assert_eq!(
        count(&export.musicxml, "<tuplet type=\"start\" number=\"2\"/>"),
        1
    );
    assert_eq!(
        count(&export.musicxml, "<tuplet type=\"stop\" number=\"2\"/>"),
        1
    );
    assert!(export.musicxml.contains("<actual-notes>21</actual-notes>"));
    assert!(export.musicxml.contains("<normal-notes>16</normal-notes>"));
    assert!(export.musicxml.contains("<actual-notes>7</actual-notes>"));
    assert!(export.musicxml.contains("<normal-notes>8</normal-notes>"));
}

#[test]
fn overflowing_nested_tuplet_product_warns_without_saturated_time_modification() {
    let source = concat!(
        "X:1\n",
        "M:C\n",
        "L:1/8\n",
        "K:C\n",
        "(9:8:1(9:8:1(9:8:1(9:8:1(9:8:1(9:8:1",
        "(9:8:1(9:8:1(9:8:1(9:8:1(9:8:1A|\n",
    );
    let export = export_musicxml(source).expect("deep nested tuplets should export");

    assert_balanced_xml(&export.musicxml);
    assert!(
        export
            .diagnostics
            .iter()
            .any(|diagnostic| diagnostic.code == "abc.musicxml.tuplet.time_modification_overflow"),
        "diagnostics: {:?}",
        export.diagnostics
    );
    assert!(
        !export.musicxml.contains("4294967295"),
        "overflow must not saturate into MusicXML ratios: {}",
        export.musicxml
    );
    assert_eq!(count(&export.musicxml, "<tuplet type=\"start\""), 11);
    assert_eq!(count(&export.musicxml, "<tuplet type=\"stop\""), 11);
}

#[test]
fn reduced_duration_note_types_do_not_emit_spurious_tuplets() {
    let source = "X:1\nT:Long notes\nM:4/4\nL:1/4\nK:C\nC2 D4|\n";
    let export = export_musicxml(source).expect("long note types should export");

    assert_balanced_xml(&export.musicxml);
    assert!(export.musicxml.contains("<type>half</type>"));
    assert!(export.musicxml.contains("<type>whole</type>"));
    assert!(!export.musicxml.contains("<time-modification>"));
}

#[test]
fn repeats_endings_multiple_voices_and_overlays_use_timeline_elements() {
    let source = concat!(
        "X:1\n",
        "M:2/4\n",
        "L:1/8\n",
        "K:C\n",
        "V:1\n",
        "|: C D & E F :| [1 G A | [2 B c |]\n",
        "V:2\n",
        "C2 D2|E2 F2|\n",
    );
    let export = export_musicxml(source).expect("timeline score should export");

    assert_balanced_xml(&export.musicxml);
    assert!(export.musicxml.contains("<repeat direction=\"forward\"/>"));
    assert!(export.musicxml.contains("<repeat direction=\"backward\"/>"));
    assert!(
        export
            .musicxml
            .contains("<ending number=\"1\" type=\"start\"/>")
    );
    assert!(
        export
            .musicxml
            .contains("<ending number=\"2\" type=\"start\"/>")
    );
    // V:1 (with its `&` overlay) and V:2 each become their own part. The
    // overlay still adds a second voice within V:1's part, so a backup and
    // `<voice>2</voice>` appear; V:2 is part P2, not a third voice.
    assert_eq!(count(&export.musicxml, "<part id="), 2);
    assert!(export.musicxml.contains("<part id=\"P2\""));
    assert!(export.musicxml.contains("<backup>"));
    assert!(export.musicxml.contains("<voice>2</voice>"));
    assert!(!export.musicxml.contains("<voice>3</voice>"));
}

#[test]
fn multi_pass_volta_merges_passes_into_single_ending_element() {
    let source = concat!(
        "X:1\n",
        "T:Volta\n",
        "M:2/4\n",
        "L:1/8\n",
        "K:G\n",
        "|: GABc |1,3 GA :|2,4 Bc :|\n",
    );
    let export = export_musicxml(source).expect("multi-pass volta score should export");

    assert_balanced_xml(&export.musicxml);
    // Each volta bracket lists its passes as a single comma-separated `number`.
    assert!(
        export
            .musicxml
            .contains("<ending number=\"1,3\" type=\"start\"")
    );
    assert!(
        export
            .musicxml
            .contains("<ending number=\"2,4\" type=\"start\"")
    );
    // The passes must NOT be split into separate `<ending>` elements.
    assert!(
        !export
            .musicxml
            .contains("<ending number=\"1\" type=\"start\"")
    );
    assert!(
        !export
            .musicxml
            .contains("<ending number=\"3\" type=\"start\"")
    );
    assert!(
        !export
            .musicxml
            .contains("<ending number=\"2\" type=\"start\"")
    );
    assert!(
        !export
            .musicxml
            .contains("<ending number=\"4\" type=\"start\"")
    );
}

#[test]
fn semantic_onset_gaps_emit_forward() {
    let source = "X:1\nL:1/8\nK:C\nC D|\n";
    let document = parse_document(source, ParseOptions::default());
    let tune = crate::parse::parse_tune_report_from_document(&document.value)
        .value
        .expect("expected tune");
    let mut score = tune.score;
    score.parts[0].voices[0].events[1].onset = Fraction::new(2, 8);
    let report = write_score_partwise(&score);

    assert_balanced_xml(&report.value);
    assert!(report.value.contains("<forward>"));
    assert!(report.value.contains("<duration>4</duration>"));
}

#[test]
fn unsupported_decoration_diagnoses_without_dropping_note_or_timing() {
    let source = "X:1\nL:1/8\nK:C\n!unknown!C D|\n";
    let export = export_musicxml(source).expect("unsupported decoration should recover");

    assert_balanced_xml(&export.musicxml);
    assert_diagnostic_span(
        source,
        &export.diagnostics,
        "abc.musicxml.decoration.unsupported",
        "!unknown!",
    );
    assert!(!export.musicxml.contains("<words>unknown</words>"));
    assert_eq!(count(&export.musicxml, "<note>"), 2);
    assert_eq!(count(&export.musicxml, "<duration>4</duration>"), 2);
}

#[test]
fn variable_duration_chord_diagnoses_and_keeps_following_timing_valid() {
    let source = "X:1\nL:1/8\nK:C\n[E2G6] C|\n";
    let export = export_musicxml(source).expect("variable chord should recover");

    assert_balanced_xml(&export.musicxml);
    assert_diagnostic_span(
        source,
        &export.diagnostics,
        "abc.music.chord.variable_duration",
        "[E2G6]",
    );
    assert_diagnostic_span(
        source,
        &export.diagnostics,
        "abc.musicxml.chord.variable_duration",
        "E2G6",
    );
    assert_eq!(count(&export.musicxml, "<chord/>"), 1);
    assert_eq!(count(&export.musicxml, "<note>"), 3);
    assert!(!export.musicxml.contains("<backup>"));
}

#[test]
fn incomplete_overlay_diagnoses_and_later_measures_stay_stable() {
    let source = "X:1\nL:1/8\nK:C\nC D & E|F G|\n";
    let export = export_musicxml(source).expect("incomplete overlay should recover");

    assert_balanced_xml(&export.musicxml);
    assert_diagnostic_span(
        source,
        &export.diagnostics,
        "abc.voice.overlay_incomplete_measure",
        "&",
    );
    assert!(export.musicxml.contains("<measure number=\"2\">"));
    assert!(export.musicxml.contains("<step>F</step>"));
    assert!(export.musicxml.contains("<step>G</step>"));
}

#[test]
fn rest_led_tuplet_emits_tuplet_start_on_the_rest() {
    // `(3zBA`: the leading rest carries the tuplet Start, so its <note> emits
    // <tuplet type="start"> — matching the abc2xml oracle.
    let source = "X:1\nL:1/8\nK:C\n(3zBA F|\n";
    let export = export_musicxml(source).expect("rest-led tuplet should export");

    assert_balanced_xml(&export.musicxml);
    let rest_note = export
        .musicxml
        .split("<note>")
        .skip(1)
        .find(|note| note.contains("<rest/>"))
        .expect("expected a rest note");
    assert!(rest_note.contains("<tuplet type=\"start\""));
    assert!(export.musicxml.contains("<tuplet type=\"stop\""));
}

#[test]
fn bad_tuplet_count_diagnoses_without_bogus_tuplet_notation_pairs() {
    let source = "X:1\nL:1/8\nK:C\n(3C|D E|\n";
    let export = export_musicxml(source).expect("short tuplet should recover");

    assert_balanced_xml(&export.musicxml);
    assert_diagnostic_span(
        source,
        &export.diagnostics,
        "abc.music.tuplet.too_few_notes",
        "(3",
    );
    assert!(export.musicxml.contains("<time-modification>"));
    assert!(!export.musicxml.contains("<tuplet type=\"start\""));
    assert!(!export.musicxml.contains("<tuplet type=\"stop\""));
    assert!(export.musicxml.contains("<measure number=\"2\">"));
}

#[test]
fn unmatched_tie_and_slur_do_not_create_musicxml_pairs() {
    let source = "X:1\nL:1/8\nK:C\nC- D )E|\n";
    let export = export_musicxml(source).expect("unmatched tie and slur should recover");

    assert_balanced_xml(&export.musicxml);
    assert_diagnostic_span(source, &export.diagnostics, "abc.music.unmatched_tie", "-");
    assert_diagnostic_span(source, &export.diagnostics, "abc.music.unmatched_slur", ")");
    assert!(!export.musicxml.contains("<tie "));
    assert!(!export.musicxml.contains("<tied "));
    assert!(!export.musicxml.contains("<slur "));
    assert_eq!(count(&export.musicxml, "<note>"), 3);
}

#[test]
fn malformed_repeat_ending_keeps_measure_structure_valid() {
    let source = "X:1\nL:1/8\nK:C\nC|[1- D|E|\n";
    let export = export_musicxml(source).expect("malformed repeat ending should recover");

    assert_balanced_xml(&export.musicxml);
    assert_diagnostic_span(
        source,
        &export.diagnostics,
        "abc.music.invalid_repeat_ending",
        "[1-",
    );
    assert!(!export.musicxml.contains("<ending number=\"1-\""));
    assert!(export.musicxml.contains("<measure number=\"1\">"));
    assert!(export.musicxml.contains("<measure number=\"2\">"));
    assert!(export.musicxml.contains("<measure number=\"3\">"));
    assert_eq!(count(&export.musicxml, "<note>"), 3);
}

#[test]
fn non_integral_duration_reports_precise_writer_diagnostic() {
    let source = "X:1\nL:1/8\nK:C\nC D|\n";
    let document = parse_document(source, ParseOptions::default());
    let tune = crate::parse::parse_tune_report_from_document(&document.value)
        .value
        .expect("expected tune");
    let mut score = tune.score;
    score.divisions = 1;
    let report = write_score_partwise(&score);

    assert_balanced_xml(&report.value);
    assert!(
        report
            .diagnostics
            .iter()
            .any(|diagnostic| diagnostic.code == "abc.musicxml.duration.non_integral")
    );
    assert!(report.value.contains("<duration>1</duration>"));
}

#[test]
fn unsupported_note_type_duration_reports_precise_writer_diagnostic() {
    let source = "X:1\nL:1/8\nK:C\nC|\n";
    let document = parse_document(source, ParseOptions::default());
    let tune = crate::parse::parse_tune_report_from_document(&document.value)
        .value
        .expect("expected tune");
    let mut score = tune.score;
    score.divisions = 13;
    score.parts[0].voices[0].events[0].duration = Fraction::new(7, 13);
    let report = write_score_partwise(&score);

    assert_balanced_xml(&report.value);
    assert!(
        report
            .diagnostics
            .iter()
            .any(|diagnostic| diagnostic.code == "abc.musicxml.duration.unsupported_note_type")
    );
    assert!(report.value.contains("<duration>28</duration>"));
}

#[test]
fn unknown_directive_is_direction_metadata_not_music() {
    let source = "X:1\nK:C\n%%foo bar & < >\nC|\n";
    let export = export_musicxml(source).expect("unknown directive should recover");

    assert_balanced_xml(&export.musicxml);
    assert_diagnostic_span(
        source,
        &export.diagnostics,
        "abc.directive.unsupported",
        "foo",
    );
    // The unsupported directive is reported as a diagnostic but, being a
    // preserved `%%` stylesheet directive, is not rendered as printed words.
    assert!(!export.musicxml.contains("%%foo"));
    assert_eq!(count(&export.musicxml, "<note>"), 1);
}

#[test]
fn unclosed_chord_bracket_before_barline_preserves_following_measures() {
    // An unclosed `[` (here followed by a space then a repeat-start) must not
    // swallow the real measures that follow it. The chord scan stops at the
    // barline so `CDEF` and `GABc` survive as two measures.
    let source = "X:1\nM:2/4\nL:1/8\nK:C\n[ |: CDEF | GABc |\n";
    let export = export_musicxml(source).expect("unclosed chord run should still export");

    assert!(
        count(&export.musicxml, "<measure ") >= 2,
        "expected at least two surviving measures, got {}",
        count(&export.musicxml, "<measure ")
    );
    for step in ['C', 'D', 'E', 'F', 'G', 'A', 'B'] {
        assert!(
            export.musicxml.contains(&format!("<step>{step}</step>")),
            "expected note {step} to survive the unclosed chord"
        );
    }
}

#[test]
fn unclosed_chord_member_before_barline_exports_pickup_note() {
    let source = "X:1\nM:4/4\nL:1/8\nK:C\ne2 E E2 ][ f |\n";
    let export = export_musicxml(source).expect("unclosed chord pickup should still export");

    assert_balanced_xml(&export.musicxml);
    assert_diagnostic_span(
        source,
        &export.diagnostics,
        "abc.music.unclosed_chord",
        "[ f ",
    );
    let steps = musicxml_notes(&export.musicxml)
        .iter()
        .filter_map(|note| note.step)
        .collect::<Vec<_>>();
    assert_eq!(
        steps,
        vec!['E', 'E', 'E', 'F'],
        "the F inside the unclosed bracket should survive as the pickup note"
    );
}

#[test]
fn unclosed_chord_quoted_run_before_barline_exports_member_notes() {
    let source = "X:1\nM:4/4\nL:1/8\nK:C\n|[F\"cont\" \"Am7\"GAB cAFA |\n";
    let export = export_musicxml(source).expect("unclosed quoted chord run should still export");

    assert_balanced_xml(&export.musicxml);
    assert_diagnostic_span(
        source,
        &export.diagnostics,
        "abc.music.unclosed_chord",
        "[F\"cont\" \"Am7\"GAB cAFA ",
    );
    let steps = musicxml_notes(&export.musicxml)
        .iter()
        .filter_map(|note| note.step)
        .collect::<Vec<_>>();
    assert_eq!(
        steps,
        vec!['F', 'G', 'A', 'B', 'C', 'A', 'F', 'A'],
        "all notes inside the quoted unclosed bracket should survive in order"
    );
}

#[test]
fn unclosed_chord_with_quoted_text_before_barline_preserves_measures() {
    // A quoted text inside an actual unclosed chord bracket must also stop at
    // the barline rather than eating the following measures.
    let source = "X:1\nM:2/4\nL:1/8\nK:C\n[C\"x\"DEF | GABc |\n";
    let export = export_musicxml(source).expect("unclosed quoted chord run should still export");

    assert_diagnostic_span(
        source,
        &export.diagnostics,
        "abc.music.unclosed_chord",
        "[C\"x\"DEF ",
    );
    // The chord scan stops at the first bar line, so the measure that follows
    // the unclosed bracket (`GABc`) is no longer swallowed. Before the fix the
    // scan ran to end-of-line, eating both bar lines and discarding every
    // following measure; now the music after the unclosed run survives.
    assert!(
        count(&export.musicxml, "<measure ") >= 1,
        "expected at least one surviving measure, got {}",
        count(&export.musicxml, "<measure ")
    );
    for step in ['G', 'A', 'B'] {
        assert!(
            export.musicxml.contains(&format!("<step>{step}</step>")),
            "expected note {step} after the unclosed quoted chord to survive"
        );
    }
}

fn count(haystack: &str, needle: &str) -> usize {
    haystack.matches(needle).count()
}

fn musicxml_note_blocks(xml: &str) -> Vec<&str> {
    let mut notes = Vec::new();
    let mut index = 0;
    while let Some(offset) = xml[index..].find("<note") {
        let start = index + offset;
        let open_end = xml[start..]
            .find('>')
            .map(|end| start + end)
            .expect("note start tag should terminate");
        let end_tag = "</note>";
        let end = xml[open_end..]
            .find(end_tag)
            .map(|end| open_end + end + end_tag.len())
            .expect("note should have closing tag");
        notes.push(&xml[start..end]);
        index = end;
    }
    notes
}

#[derive(Debug, PartialEq, Eq)]
struct SlurEvent {
    voice: String,
    step: char,
    slur_type: String,
    number: String,
}

impl SlurEvent {
    fn new(voice: &str, step: char, slur_type: &str, number: &str) -> Self {
        Self {
            voice: voice.to_owned(),
            step,
            slur_type: slur_type.to_owned(),
            number: number.to_owned(),
        }
    }
}

fn slur_events_in_note(note: &str) -> Vec<SlurEvent> {
    if !note.contains("<slur ") {
        return Vec::new();
    }
    let voice = element_text(note, "voice").expect("slurred note should have voice");
    let step = element_text(note, "step")
        .and_then(|step| step.chars().next())
        .expect("slurred note should have pitch step");
    let mut events = Vec::new();
    let mut index = 0;
    while let Some(offset) = note[index..].find("<slur ") {
        let start = index + offset;
        let end = note[start..]
            .find("/>")
            .map(|end| start + end + 2)
            .expect("slur tag should close");
        let slur = &note[start..end];
        events.push(SlurEvent {
            voice: voice.clone(),
            step,
            slur_type: attr_value(slur, "type").expect("slur should have type"),
            number: attr_value(slur, "number").expect("slur should have number"),
        });
        index = end;
    }
    events
}

fn musicxml_part_blocks(xml: &str) -> Vec<&str> {
    let mut parts = Vec::new();
    let mut index = 0;
    while let Some(offset) = xml[index..].find("<part id=") {
        let start = index + offset;
        let open_end = xml[start..]
            .find('>')
            .map(|end| start + end)
            .expect("part start tag should terminate");
        let end_tag = "</part>";
        let end = xml[open_end..]
            .find(end_tag)
            .map(|end| open_end + end + end_tag.len())
            .expect("part should have closing tag");
        parts.push(&xml[start..end]);
        index = end;
    }
    parts
}

#[derive(Debug)]
struct XmlMeasure {
    number: String,
    notes: Vec<XmlNote>,
    barlines: Vec<XmlBarline>,
}

#[derive(Debug)]
struct XmlNote {
    rest: bool,
    step: Option<char>,
    duration: Option<u32>,
    grace: bool,
}

#[derive(Debug)]
struct XmlBarline {
    location: String,
    bar_style: Option<String>,
    repeat_direction: Option<String>,
    repeat_times: Option<String>,
    endings: Vec<XmlEnding>,
}

#[derive(Debug)]
struct XmlEnding {
    number: String,
    kind: String,
}

fn musicxml_measures(xml: &str) -> Vec<XmlMeasure> {
    let mut measures = Vec::new();
    let mut index = 0;
    while let Some(offset) = xml[index..].find("<measure ") {
        let start = index + offset;
        let open_end = xml[start..]
            .find('>')
            .map(|end| start + end)
            .expect("measure start tag should terminate");
        let end_tag = "</measure>";
        let end = xml[open_end..]
            .find(end_tag)
            .map(|end| open_end + end)
            .expect("measure should have closing tag");
        let open_tag = &xml[start..=open_end];
        let body = &xml[open_end + 1..end];
        measures.push(XmlMeasure {
            number: attr_value(open_tag, "number").expect("measure should have number"),
            notes: musicxml_notes(body),
            barlines: musicxml_barlines(body),
        });
        index = end + end_tag.len();
    }
    measures
}

fn measure_body<'a>(xml: &'a str, number: &str) -> &'a str {
    let start_tag = format!("<measure number=\"{number}\">");
    let start = xml.find(&start_tag).expect("measure should exist") + start_tag.len();
    let end = xml[start..]
        .find("</measure>")
        .map(|end| start + end)
        .expect("measure should close");
    &xml[start..end]
}

fn musicxml_notes(xml: &str) -> Vec<XmlNote> {
    let mut notes = Vec::new();
    let mut index = 0;
    while let Some(offset) = xml[index..].find("<note") {
        let start = index + offset;
        let open_end = xml[start..]
            .find('>')
            .map(|end| start + end)
            .expect("note start tag should terminate");
        let end_tag = "</note>";
        let end = xml[open_end..]
            .find(end_tag)
            .map(|end| open_end + end)
            .expect("note should have closing tag");
        let body = &xml[open_end + 1..end];
        notes.push(XmlNote {
            rest: body.contains("<rest"),
            step: element_text(body, "step").and_then(|text| text.chars().next()),
            duration: element_text(body, "duration").and_then(|text| text.parse().ok()),
            grace: body.contains("<grace"),
        });
        index = end + end_tag.len();
    }
    notes
}

fn musicxml_barlines(xml: &str) -> Vec<XmlBarline> {
    let mut barlines = Vec::new();
    let mut index = 0;
    while let Some(offset) = xml[index..].find("<barline ") {
        let start = index + offset;
        let open_end = xml[start..]
            .find('>')
            .map(|end| start + end)
            .expect("barline start tag should terminate");
        let end_tag = "</barline>";
        let end = xml[open_end..]
            .find(end_tag)
            .map(|end| open_end + end)
            .expect("barline should have closing tag");
        let open_tag = &xml[start..=open_end];
        let body = &xml[open_end + 1..end];
        let repeat_direction = body.find("<repeat ").and_then(|offset| {
            let repeat_start = offset;
            let repeat_end = body[repeat_start..]
                .find('>')
                .map(|end| repeat_start + end)?;
            attr_value(&body[repeat_start..=repeat_end], "direction")
        });
        let repeat_times = body.find("<repeat ").and_then(|offset| {
            let repeat_start = offset;
            let repeat_end = body[repeat_start..]
                .find('>')
                .map(|end| repeat_start + end)?;
            attr_value(&body[repeat_start..=repeat_end], "times")
        });
        barlines.push(XmlBarline {
            location: attr_value(open_tag, "location").expect("barline should have location"),
            bar_style: element_text(body, "bar-style"),
            repeat_direction,
            repeat_times,
            endings: musicxml_endings(body),
        });
        index = end + end_tag.len();
    }
    barlines
}

fn musicxml_endings(xml: &str) -> Vec<XmlEnding> {
    let mut endings = Vec::new();
    let mut index = 0;
    while let Some(offset) = xml[index..].find("<ending ") {
        let start = index + offset;
        let end = xml[start..]
            .find('>')
            .map(|end| start + end)
            .expect("ending tag should terminate");
        let tag = &xml[start..=end];
        endings.push(XmlEnding {
            number: attr_value(tag, "number").expect("ending should have number"),
            kind: attr_value(tag, "type").expect("ending should have type"),
        });
        index = end + 1;
    }
    endings
}

fn measure_numbers(measures: &[XmlMeasure]) -> Vec<&str> {
    measures
        .iter()
        .map(|measure| measure.number.as_str())
        .collect()
}

fn note_steps(measure: &XmlMeasure) -> Vec<char> {
    measure.notes.iter().filter_map(|note| note.step).collect()
}

fn note_durations(measure: &XmlMeasure) -> Vec<u32> {
    measure
        .notes
        .iter()
        .filter_map(|note| note.duration)
        .collect()
}

fn note_grace_flags(measure: &XmlMeasure) -> Vec<bool> {
    measure.notes.iter().map(|note| note.grace).collect()
}

fn has_barline(
    measure: &XmlMeasure,
    location: &str,
    bar_style: Option<&str>,
    repeat_direction: Option<&str>,
) -> bool {
    measure.barlines.iter().any(|barline| {
        barline.location == location
            && barline.bar_style.as_deref() == bar_style
            && barline.repeat_direction.as_deref() == repeat_direction
    })
}

fn has_ending(measure: &XmlMeasure, location: &str, number: &str, kind: &str) -> bool {
    measure.barlines.iter().any(|barline| {
        barline.location == location
            && barline
                .endings
                .iter()
                .any(|ending| ending.number == number && ending.kind == kind)
    })
}

fn attr_value(tag: &str, attr: &str) -> Option<String> {
    let pattern = format!("{attr}=\"");
    let start = tag.find(&pattern)? + pattern.len();
    let end = tag[start..].find('"')?;
    Some(tag[start..start + end].to_owned())
}

fn element_text(block: &str, element: &str) -> Option<String> {
    let open = format!("<{element}>");
    let close = format!("</{element}>");
    let start = block.find(&open)? + open.len();
    let end = block[start..].find(&close)? + start;
    Some(block[start..end].to_owned())
}

fn assert_diagnostic_span(
    source: &str,
    diagnostics: &[Diagnostic],
    code: &'static str,
    snippet: &str,
) {
    let diagnostic = diagnostics
        .iter()
        .find(|diagnostic| diagnostic.code == code)
        .unwrap_or_else(|| panic!("expected diagnostic {code}"));
    assert_eq!(&source[diagnostic.span.start..diagnostic.span.end], snippet);
}

fn assert_balanced_xml(xml: &str) {
    let mut stack: Vec<String> = Vec::new();
    let mut index = 0;
    while let Some(offset) = xml[index..].find('<') {
        let start = index + offset;
        let end = xml[start..]
            .find('>')
            .map(|end| start + end)
            .unwrap_or_else(|| panic!("unterminated XML tag at byte {start}"));
        let tag = &xml[start + 1..end];
        if tag.starts_with('?') || tag.starts_with('!') {
            index = end + 1;
            continue;
        }
        if let Some(name) = tag.strip_prefix('/') {
            let expected = stack.pop().expect("unexpected closing XML tag");
            assert_eq!(name.trim(), expected);
        } else if !tag.trim_end().ends_with('/') {
            let name = tag
                .split_whitespace()
                .next()
                .expect("XML tag should have a name")
                .trim_end_matches('/');
            stack.push(name.to_owned());
        }
        index = end + 1;
    }
    assert!(stack.is_empty(), "unclosed XML tags: {stack:?}");
}

/// Collect the `(step, alter)` of every pitched note in source order.
fn note_steps_and_alters(xml: &str) -> Vec<(char, i8)> {
    let mut out = Vec::new();
    let mut index = 0;
    while let Some(offset) = xml[index..].find("<step>") {
        let start = index + offset + "<step>".len();
        let end = start + xml[start..].find("</step>").expect("step end");
        let step = xml[start..end].chars().next().expect("step char");
        // The optional <alter> for this pitch lives between the step and the
        // closing </pitch>.
        let pitch_end = end + xml[end..].find("</pitch>").expect("pitch end");
        let alter = xml[end..pitch_end]
            .find("<alter>")
            .map(|alter_offset| {
                let alter_start = end + alter_offset + "<alter>".len();
                let alter_end =
                    alter_start + xml[alter_start..].find("</alter>").expect("alter end");
                xml[alter_start..alter_end]
                    .parse::<i8>()
                    .expect("alter int")
            })
            .unwrap_or(0);
        out.push((step, alter));
        index = pitch_end;
    }
    out
}

/// Split the partwise document into its `<part ...>...</part>` bodies.
fn part_bodies(xml: &str) -> Vec<String> {
    let mut out = Vec::new();
    let mut index = 0;
    while let Some(offset) = xml[index..].find("<part ") {
        let start = index + offset;
        let body_start = start + xml[start..].find('>').expect("part open end") + 1;
        let body_end = body_start + xml[body_start..].find("</part>").expect("part end");
        out.push(xml[body_start..body_end].to_owned());
        index = body_end;
    }
    out
}

#[test]
fn inline_key_change_scopes_to_current_voice_only() {
    // V1 switches to C in its third measure; V3 must keep key G, so its F
    // notes stay F# throughout. abc2xml keeps the other voice in key G.
    let source = concat!(
        "X:1\n",
        "M:2/4\n",
        "L:1/8\n",
        "K:G\n",
        "V:1\n",
        "A A A A | A A A A | [K:C] A A A A |\n",
        "V:3\n",
        "F F F F | F F F F | G G G G |\n",
    );
    let export = export_musicxml(source).expect("multi-voice score should export");
    assert_balanced_xml(&export.musicxml);

    let parts = part_bodies(&export.musicxml);
    assert_eq!(parts.len(), 2, "expected two voices/parts");

    // V3 (second part): the eight F notes across the first two measures must
    // all sound F# (alter +1) from key G; the inline [K:C] in V1 must not
    // wipe V3's key signature.
    let v3 = note_steps_and_alters(&parts[1]);
    let f_notes: Vec<(char, i8)> = v3.into_iter().filter(|(step, _)| *step == 'F').collect();
    assert_eq!(f_notes.len(), 8, "V3 should have eight F notes");
    for (step, alter) in f_notes {
        assert_eq!((step, alter), ('F', 1), "V3 F must stay F# under key G");
    }
}

#[test]
fn standalone_body_key_change_keeps_late_voice_in_header_key_until_own_change() {
    let source = concat!(
        "X:1\n",
        "M:4/4\n",
        "L:1/4\n",
        "K:D\n",
        "V:1\n",
        "B B B B|\n",
        "K:Dm\n",
        "B B B B|\n",
        "V:2\n",
        "F C B F|\n",
        "K:Dm\n",
        "F C B F|\n",
    );
    let export = export_musicxml(source).expect("multi-voice score should export");
    assert_balanced_xml(&export.musicxml);

    let parts = part_bodies(&export.musicxml);
    assert_eq!(parts.len(), 2, "expected two voices/parts");
    let p2_measures = parts[1].split("<measure ").collect::<Vec<_>>();
    assert!(p2_measures.len() >= 3, "expected two V2 measures");
    assert!(
        p2_measures[1].contains("<fifths>2</fifths>"),
        "V2 measure 1 keeps the header D major key: {}",
        p2_measures[1]
    );
    assert!(
        !p2_measures[1].contains("<fifths>-1</fifths>"),
        "V2 measure 1 must not receive V1's body K:Dm: {}",
        p2_measures[1]
    );
    assert!(
        p2_measures[2].contains("<fifths>-1</fifths>"),
        "V2 measure 2 carries V2's own K:Dm: {}",
        p2_measures[2]
    );
    assert_eq!(
        note_steps_and_alters(&parts[1]),
        vec![
            ('F', 1),
            ('C', 1),
            ('B', 0),
            ('F', 1),
            ('F', 0),
            ('C', 0),
            ('B', -1),
            ('F', 0),
        ],
        "V2 first bar stays in D major, second bar changes to D minor"
    );
}

#[test]
fn tie_across_barline_keeps_natural_against_flat_key() {
    // `=B-` ties a natural B across the barline; the stop note must remain
    // natural (alter 0) and not pick up key F's B-flat.
    let source = "X:1\nM:4/4\nL:1/4\nK:F\nA G =B- | B A2 z |\n";
    let export = export_musicxml(source).expect("tied score should export");
    assert_balanced_xml(&export.musicxml);

    let notes = note_steps_and_alters(&export.musicxml);
    let b_notes: Vec<(char, i8)> = notes.into_iter().filter(|(step, _)| *step == 'B').collect();
    assert_eq!(
        b_notes.len(),
        2,
        "expected two B notes (tie start and stop)"
    );
    for (step, alter) in b_notes {
        assert_eq!(
            (step, alter),
            ('B', 0),
            "tied B must stay natural across bar"
        );
    }
}

#[test]
fn tie_across_barline_keeps_flat_against_neutral_key() {
    // `_B-` ties a flat B across the barline in key C; the stop note must
    // remain flat (alter -1) rather than reverting to natural.
    let source = "X:1\nM:4/4\nL:1/4\nK:C\nA G _B- | B A2 z |\n";
    let export = export_musicxml(source).expect("tied score should export");
    assert_balanced_xml(&export.musicxml);

    let notes = note_steps_and_alters(&export.musicxml);
    let b_notes: Vec<(char, i8)> = notes.into_iter().filter(|(step, _)| *step == 'B').collect();
    assert_eq!(
        b_notes.len(),
        2,
        "expected two B notes (tie start and stop)"
    );
    for (step, alter) in b_notes {
        assert_eq!((step, alter), ('B', -1), "tied B must stay flat across bar");
    }
}

#[test]
fn mid_tune_key_and_meter_changes_emit_attributes() {
    let source = "X:1\nL:1/4\nK:C\nCDEF|[K:F]GAB_B|[M:3/4]ABc|\n";
    let export = export_musicxml(source).expect("score should export");
    let xml = export.musicxml;
    // measure 2 opens with a key change to one flat; measure 3 with 3/4 time
    let measures: Vec<&str> = xml.split("<measure ").collect();
    assert!(measures.len() >= 4, "three measures: {xml}");
    assert!(
        measures[2].contains("<fifths>-1</fifths>"),
        "measure 2 carries the key change: {}",
        measures[2]
    );
    assert!(
        measures[3].contains("<beats>3</beats>")
            && measures[3].contains("<beat-type>4</beat-type>"),
        "measure 3 carries the meter change: {}",
        measures[3]
    );
    // header attributes unchanged
    assert!(measures[1].contains("<fifths>0</fifths>"));
}

#[test]
fn mid_measure_key_change_emits_attributes_between_notes() {
    let source = "X:1\nL:1/4\nK:C\nCD[K:D]EF|\n";
    let export = export_musicxml(source).expect("score should export");
    let xml = export.musicxml;
    // the second <attributes> (fifths 2) appears after the 2nd note and
    // before the 3rd note within measure 1
    let m1_start = xml.find("<measure ").expect("measure 1");
    let m1 = &xml[m1_start..];
    let change = m1
        .find("<fifths>2</fifths>")
        .expect("mid-measure key change");
    let notes: Vec<usize> = m1.match_indices("<note>").map(|(i, _)| i).collect();
    assert!(notes.len() >= 4);
    assert!(
        notes[1] < change && change < notes[2],
        "key change sits between note 2 and note 3"
    );
}

#[test]
fn grace_implicit_alter_uses_position_active_key() {
    // After [K:D] (two sharps), an unmarked grace f must export F#.
    let source = "X:1\nL:1/4\nK:C\nCDEF|[K:D]{f}GABc|\n";
    let export = export_musicxml(source).expect("score should export");
    let xml = export.musicxml;
    let grace_at = xml.find("<grace/>").expect("grace note present");
    let after = &xml[grace_at..];
    assert!(
        after.contains("<step>F</step>")
            && after[..after.find("<octave>").expect("octave")].contains("<alter>1</alter>"),
        "grace F carries the new key's sharp: {}",
        &after[..300.min(after.len())]
    );
}

#[test]
fn tie_keeps_pitch_across_mid_measure_key_change() {
    // The tie-stop F must stay F# (the tied pitch), not re-resolve to F
    // natural under the new key — and the reverse direction likewise.
    for (source, want_alter) in [
        ("X:1\nM:4/4\nL:1/4\nK:D\nFGAF|F-[K:C]FGA|\n", "1"),
        ("X:1\nM:4/4\nL:1/4\nK:C\nFGAF|F-[K:D]FGA|\n", "0"),
    ] {
        let export = export_musicxml(source).expect("score should export");
        let xml = export.musicxml;
        let stop = xml
            .find("tied type=\"stop\"")
            .or_else(|| xml.find("<tie type=\"stop\""))
            .expect("tie stop present");
        let before = &xml[..stop];
        let note_start = before.rfind("<note>").expect("stop note");
        let note = &xml[note_start..stop];
        let alter = note
            .find("<alter>")
            .map(|i| &note[i + 7..i + 8])
            .unwrap_or("0");
        assert_eq!(alter, want_alter, "tie-stop alter for {source:?}: {note}");
    }
}

#[test]
fn free_meter_change_emits_no_empty_attributes() {
    let source = "X:1\nM:4/4\nL:1/8\nK:C\nCDEF|[M:none]GABcdefg|\n";
    let export = export_musicxml(source).expect("score should export");
    assert!(
        !export.musicxml.contains("<attributes></attributes>")
            && !export.musicxml.contains("<attributes/>"),
        "no empty attributes wrapper"
    );
}

#[test]
fn missing_header_meter_stays_free_until_body_meter_change() {
    let source = "X:1\nL:1/8\nK:C\nG4 E4 C4 D4\n[M:3/8]\n| GEA |\n";
    let export = export_musicxml(source).expect("score should export");
    let xml = export.musicxml;

    assert_balanced_xml(&xml);
    assert_eq!(count(&xml, "<time>"), 1);
    assert!(!xml.contains("<beats>4</beats>"));
    assert!(
        xml.contains("<beats>3</beats>\n          <beat-type>8</beat-type>"),
        "body meter change should still export 3/8: {xml}"
    );
}

#[test]
fn complex_header_meter_exports_additive_time() {
    let source = "X:1\nM:(2+3+2)/8\nL:1/8\nK:C\nCDEFGAB|\n";
    let export = export_musicxml(source).expect("score should export");
    let xml = export.musicxml;

    assert_balanced_xml(&xml);
    assert_eq!(count(&xml, "<time>"), 1);
    assert!(xml.contains("<beats>2+3+2</beats>"));
    assert!(xml.contains("<beat-type>8</beat-type>"));
}

#[test]
fn additive_extension_header_meter_exports_composite_time() {
    let source = "X:1\nM:3/4+2/4\nL:1/4\nK:C\nCDEFG|\n";
    let export = export_musicxml(source).expect("score should export");
    let xml = export.musicxml;

    assert_balanced_xml(&xml);
    assert_eq!(count(&xml, "<time>"), 1);
    assert!(xml.contains("<beats>3</beats>"));
    assert!(xml.contains("<beat-type>4</beat-type>"));
    assert!(xml.contains("<beats>2</beats>"));
    assert_eq!(count(&xml, "<beat-type>4</beat-type>"), 2);
}
