pub const MIN_STRINGS: usize = 2;
pub const MAX_STRINGS: usize = 12;
pub const DEFAULT_FRETS_SHOWN: usize = 5;
pub const MAX_BASE_FRET: u32 = 24;
pub const MIN_FRETS_SHOWN: usize = 1;
pub const MAX_FRETS_SHOWN: usize = 24;
#[derive(Debug, Clone)]
pub struct DiagramData {
pub name: String,
pub display_name: Option<String>,
pub strings: usize,
pub frets_shown: usize,
pub base_fret: u32,
pub frets: Vec<i32>,
pub fingers: Vec<u8>,
}
impl DiagramData {
#[must_use]
pub fn title(&self) -> &str {
self.display_name.as_deref().unwrap_or(&self.name)
}
}
impl DiagramData {
#[must_use]
pub fn from_raw_infer(name: &str, raw: &str) -> Option<Self> {
Self::from_raw(name, raw, 0)
}
#[must_use]
pub fn from_raw_infer_frets(name: &str, raw: &str, frets_shown: usize) -> Option<Self> {
Self::from_raw_frets(name, raw, 0, frets_shown)
}
#[must_use]
pub fn from_raw(name: &str, raw: &str, num_strings: usize) -> Option<Self> {
Self::from_raw_frets(name, raw, num_strings, DEFAULT_FRETS_SHOWN)
}
#[must_use]
pub fn from_raw_frets(
name: &str,
raw: &str,
num_strings: usize,
frets_shown: usize,
) -> Option<Self> {
let mut base_fret: u32 = 1;
let mut frets: Vec<i32> = Vec::new();
let mut fingers: Vec<u8> = Vec::new();
let tokens: Vec<&str> = raw.split_whitespace().collect();
let mut i = 0;
while i < tokens.len() {
let tok_lower = tokens[i].to_ascii_lowercase();
match tok_lower.as_str() {
"base-fret" if i + 1 < tokens.len() => {
base_fret = tokens[i + 1].parse().unwrap_or(1).clamp(1, MAX_BASE_FRET);
i += 2;
}
"base-fret" => {
i += 1;
}
"frets" => {
i += 1;
while i < tokens.len() {
let low = tokens[i].to_ascii_lowercase();
if matches!(
low.as_str(),
"frets" | "fingers" | "base-fret" | "display" | "format"
) {
break;
}
let val = match low.as_str() {
"x" | "n" => -1,
s => {
let v = s.parse::<i32>().unwrap_or(-1);
if v < -1 { -1 } else { v }
}
};
frets.push(val);
i += 1;
}
}
"fingers" => {
i += 1;
while i < tokens.len() {
let low = tokens[i].to_ascii_lowercase();
if matches!(
low.as_str(),
"frets" | "fingers" | "base-fret" | "display" | "format"
) {
break;
}
let Ok(n) = tokens[i].parse::<u8>() else {
break;
};
fingers.push(n);
i += 1;
}
}
_ => {
i += 1;
}
}
}
if frets.is_empty() {
return None;
}
let strings = num_strings.max(frets.len());
if !(MIN_STRINGS..=MAX_STRINGS).contains(&strings) {
return None;
}
let frets_shown = frets_shown.clamp(MIN_FRETS_SHOWN, MAX_FRETS_SHOWN);
let frets: Vec<i32> = frets
.into_iter()
.map(|f| {
if f > frets_shown as i32 {
frets_shown as i32
} else {
f
}
})
.collect();
Some(Self {
name: name.to_string(),
display_name: None,
strings,
frets_shown,
base_fret,
frets,
fingers,
})
}
}
const CELL_W: f32 = 16.0;
const CELL_H: f32 = 20.0;
const TOP_MARGIN: f32 = 30.0;
const LEFT_MARGIN: f32 = 20.0;
const DOT_RADIUS: f32 = 5.0;
const OPEN_RADIUS: f32 = 4.0;
const POSITION_DOT_RADIUS: f32 = 2.2;
fn position_marker_frets(strings: usize) -> &'static [u8] {
match strings {
6 => &[3, 5, 7, 9, 12, 15, 17, 19, 21],
4 => &[3, 5, 7, 10, 12, 15],
_ => &[],
}
}
#[must_use]
pub fn render_svg(data: &DiagramData) -> String {
if data.strings < MIN_STRINGS
|| data.strings > MAX_STRINGS
|| data.frets_shown < MIN_FRETS_SHOWN
|| data.frets_shown > MAX_FRETS_SHOWN
{
return String::new();
}
let num_strings = data.strings;
let num_frets = data.frets_shown;
let grid_w = (num_strings - 1) as f32 * CELL_W;
let grid_h = num_frets as f32 * CELL_H;
let total_w = grid_w + LEFT_MARGIN * 2.0;
let total_h = grid_h + TOP_MARGIN + 30.0;
let mut svg = format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{total_w}\" height=\"{total_h}\" \
viewBox=\"0 0 {total_w} {total_h}\" class=\"chord-diagram\">\n"
);
let name_x = LEFT_MARGIN + grid_w / 2.0;
svg.push_str(&format!(
"<text x=\"{name_x}\" y=\"15\" text-anchor=\"middle\" \
font-family=\"sans-serif\" font-size=\"14\" font-weight=\"bold\">{}</text>\n",
crate::escape::escape_xml(data.title())
));
let nut_y = TOP_MARGIN;
if data.base_fret == 1 {
svg.push_str(&format!(
"<line x1=\"{LEFT_MARGIN}\" y1=\"{nut_y}\" x2=\"{}\" y2=\"{nut_y}\" \
stroke=\"black\" stroke-width=\"3\"/>\n",
LEFT_MARGIN + grid_w
));
} else {
svg.push_str(&format!(
"<text x=\"{}\" y=\"{}\" text-anchor=\"end\" \
font-family=\"sans-serif\" font-size=\"10\">{}</text>\n",
LEFT_MARGIN - 4.0,
nut_y + CELL_H / 2.0 + 3.0,
data.base_fret
));
}
let position_frets = position_marker_frets(num_strings);
let center_x = LEFT_MARGIN + grid_w / 2.0;
for &marker_fret in position_frets {
if (marker_fret as i32) < data.base_fret as i32 {
continue;
}
let row = (marker_fret as i32) - (data.base_fret as i32) + 1;
if row < 1 || row > num_frets as i32 {
continue;
}
let y = nut_y + (row as f32 - 0.5) * CELL_H;
if marker_fret == 12 {
svg.push_str(&format!(
"<circle cx=\"{cx1}\" cy=\"{y}\" r=\"{POSITION_DOT_RADIUS}\" \
fill=\"#D4D1D6\" class=\"position-marker\"/>\n\
<circle cx=\"{cx2}\" cy=\"{y}\" r=\"{POSITION_DOT_RADIUS}\" \
fill=\"#D4D1D6\" class=\"position-marker\"/>\n",
cx1 = center_x - CELL_W * 0.55,
cx2 = center_x + CELL_W * 0.55,
));
} else {
svg.push_str(&format!(
"<circle cx=\"{center_x}\" cy=\"{y}\" r=\"{POSITION_DOT_RADIUS}\" \
fill=\"#D4D1D6\" class=\"position-marker\"/>\n"
));
}
}
for i in 0..num_strings {
let x = LEFT_MARGIN + i as f32 * CELL_W;
svg.push_str(&format!(
"<line x1=\"{x}\" y1=\"{nut_y}\" x2=\"{x}\" y2=\"{}\" \
stroke=\"black\" stroke-width=\"1\"/>\n",
nut_y + grid_h
));
}
for j in 0..=num_frets {
let y = nut_y + j as f32 * CELL_H;
svg.push_str(&format!(
"<line x1=\"{LEFT_MARGIN}\" y1=\"{y}\" x2=\"{}\" y2=\"{y}\" \
stroke=\"black\" stroke-width=\"1\"/>\n",
LEFT_MARGIN + grid_w
));
}
for (i, &fret) in data.frets.iter().enumerate() {
if i >= num_strings {
break;
}
let x = LEFT_MARGIN + i as f32 * CELL_W;
if fret == -1 {
let y = nut_y - 10.0;
svg.push_str(&format!(
"<text x=\"{x}\" y=\"{y}\" text-anchor=\"middle\" \
font-family=\"sans-serif\" font-size=\"10\">X</text>\n"
));
} else if fret == 0 {
let y = nut_y - 10.0;
svg.push_str(&format!(
"<circle cx=\"{x}\" cy=\"{y}\" r=\"{OPEN_RADIUS}\" \
fill=\"none\" stroke=\"black\" stroke-width=\"1\"/>\n"
));
} else {
let y = nut_y + (fret as f32 - 0.5) * CELL_H;
svg.push_str(&format!(
"<circle cx=\"{x}\" cy=\"{y}\" r=\"{DOT_RADIUS}\" fill=\"black\"/>\n"
));
if let Some(&finger) = data.fingers.get(i) {
if finger > 0 {
svg.push_str(&format!(
"<text x=\"{x}\" y=\"{}\" text-anchor=\"middle\" \
font-family=\"sans-serif\" font-size=\"8\" \
fill=\"white\">{finger}</text>\n",
y + 3.0
));
}
}
}
}
svg.push_str("</svg>");
svg
}
#[must_use]
pub fn render_ascii(data: &DiagramData) -> String {
let title = data.title();
let mut positions: Vec<String> = Vec::with_capacity(data.frets.len());
for &f in &data.frets {
match f {
-1 => positions.push("x".to_string()),
0 => positions.push("o".to_string()),
n => {
let abs_fret = data.base_fret as i32 + n - 1;
positions.push(abs_fret.to_string());
}
}
}
let frets_str = positions.join(" ");
if data.base_fret > 1 {
format!("{title}\n{frets_str} (fr. {base})", base = data.base_fret)
} else {
format!("{title}\n{frets_str}")
}
}
#[derive(Debug, Clone)]
pub struct KeyboardVoicing {
pub name: String,
pub display_name: Option<String>,
pub keys: Vec<u8>,
pub root_key: u8,
}
impl KeyboardVoicing {
#[must_use]
pub fn title(&self) -> &str {
self.display_name.as_deref().unwrap_or(&self.name)
}
}
const KBD_WHITE_W: f32 = 15.0;
const KBD_WHITE_H: f32 = 60.0;
const KBD_BLACK_W: f32 = 9.0;
const KBD_BLACK_H: f32 = 36.0;
const KBD_TOP_PAD: f32 = 30.0;
const KBD_SIDE_PAD: f32 = 8.0;
const WHITE_KEY_POSITIONS: [(u8, f32); 7] = [
(0, 0.0 * KBD_WHITE_W), (2, 1.0 * KBD_WHITE_W), (4, 2.0 * KBD_WHITE_W), (5, 3.0 * KBD_WHITE_W), (7, 4.0 * KBD_WHITE_W), (9, 5.0 * KBD_WHITE_W), (11, 6.0 * KBD_WHITE_W), ];
const BLACK_KEY_POSITIONS: [(u8, f32); 5] = [
(1, 10.0), (3, 25.0), (6, 55.0), (8, 70.0), (10, 85.0), ];
#[must_use]
pub fn normalise_keyboard_keys(keys: &[u8], root_key: u8) -> (Vec<u8>, u8) {
if keys.iter().all(|&k| k < 12) {
let normalised: Vec<u8> = keys.iter().map(|&k| k.saturating_add(60)).collect();
let root = if root_key < 12 {
root_key.saturating_add(60)
} else {
root_key
};
(normalised, root)
} else {
(keys.to_vec(), root_key)
}
}
#[must_use]
pub fn render_keyboard_svg(voicing: &KeyboardVoicing) -> String {
if voicing.keys.is_empty() {
return String::new();
}
let (keys, root) = normalise_keyboard_keys(&voicing.keys, voicing.root_key);
let min_key = *keys.iter().min().unwrap_or(&60);
let max_key = *keys.iter().max().unwrap_or(&71);
let start_octave = u32::from(min_key / 12);
let end_octave = u32::from(max_key / 12);
let num_octaves = ((end_octave - start_octave) + 1).clamp(2, 3) as usize;
let start_midi = (start_octave * 12) as u8;
let kbd_w = num_octaves as f32 * 7.0 * KBD_WHITE_W;
let total_w = kbd_w + KBD_SIDE_PAD * 2.0;
let total_h = KBD_TOP_PAD + KBD_WHITE_H + 8.0;
let mut svg = format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{total_w}\" height=\"{total_h}\" \
viewBox=\"0 0 {total_w} {total_h}\" class=\"keyboard-diagram\">\n"
);
let name_x = total_w / 2.0;
svg.push_str(&format!(
"<text x=\"{name_x}\" y=\"15\" text-anchor=\"middle\" \
font-family=\"sans-serif\" font-size=\"14\" font-weight=\"bold\">{}</text>\n",
crate::escape::escape_xml(voicing.title())
));
for oct in 0..num_octaves {
let oct_midi = start_midi.saturating_add((oct * 12) as u8);
let oct_x = KBD_SIDE_PAD + oct as f32 * 7.0 * KBD_WHITE_W;
for (semitone, x_off) in WHITE_KEY_POSITIONS {
let midi = oct_midi.saturating_add(semitone);
let x = oct_x + x_off;
let highlighted = keys.contains(&midi);
let is_root = highlighted && midi == root;
let fill = if is_root {
"#1a5fb4" } else if highlighted {
"#4a90e2" } else {
"white"
};
svg.push_str(&format!(
"<rect x=\"{x}\" y=\"{KBD_TOP_PAD}\" width=\"{KBD_WHITE_W}\" \
height=\"{KBD_WHITE_H}\" fill=\"{fill}\" stroke=\"black\" \
stroke-width=\"0.5\"/>\n"
));
}
}
for oct in 0..num_octaves {
let oct_midi = start_midi.saturating_add((oct * 12) as u8);
let oct_x = KBD_SIDE_PAD + oct as f32 * 7.0 * KBD_WHITE_W;
for (semitone, x_off) in BLACK_KEY_POSITIONS {
let midi = oct_midi.saturating_add(semitone);
let x = oct_x + x_off;
let highlighted = keys.contains(&midi);
let is_root = highlighted && midi == root;
let fill = if is_root {
"#1a5fb4" } else if highlighted {
"#4a90e2" } else {
"#222" };
svg.push_str(&format!(
"<rect x=\"{x}\" y=\"{KBD_TOP_PAD}\" width=\"{KBD_BLACK_W}\" \
height=\"{KBD_BLACK_H}\" fill=\"{fill}\" stroke=\"black\" \
stroke-width=\"0.5\"/>\n"
));
}
}
svg.push_str("</svg>");
svg
}
#[must_use]
pub fn resolve_diagrams_instrument(
value: Option<&str>,
default_instrument: &str,
) -> Option<String> {
let val = value.unwrap_or("on");
if val.eq_ignore_ascii_case("off") {
return None;
}
let instr = match val.to_ascii_lowercase().as_str() {
"ukulele" | "uke" => "ukulele",
"guitar" => "guitar",
"piano" | "keyboard" | "keys" => "piano",
_ => default_instrument,
};
Some(instr.to_string())
}
#[must_use]
pub fn canonical_chord_name(name: &str) -> String {
crate::voicings::flat_to_sharp(name).unwrap_or_else(|| name.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_render_svg_basic() {
let data = DiagramData {
name: "Am".to_string(),
display_name: None,
strings: 6,
frets_shown: 5,
base_fret: 1,
frets: vec![-1, 0, 2, 2, 1, 0],
fingers: vec![],
};
let svg = render_svg(&data);
assert!(svg.contains("<svg"));
assert!(svg.contains("</svg>"));
assert!(svg.contains("Am"));
assert!(svg.contains("<circle"));
assert!(svg.contains(">X<"));
}
#[test]
fn test_render_svg_barre_chord() {
let data = DiagramData {
name: "F".to_string(),
display_name: None,
strings: 6,
frets_shown: 5,
base_fret: 1,
frets: vec![1, 1, 2, 3, 3, 1],
fingers: vec![],
};
let svg = render_svg(&data);
assert!(svg.contains(">F<"));
}
#[test]
fn test_render_svg_high_position() {
let data = DiagramData {
name: "Bm".to_string(),
display_name: None,
strings: 6,
frets_shown: 5,
base_fret: 7,
frets: vec![-1, 1, 3, 3, 2, 1],
fingers: vec![],
};
let svg = render_svg(&data);
assert!(svg.contains(">7</text>"));
assert!(!svg.contains("7fr"));
}
#[test]
fn position_marker_frets_table_lookup() {
assert_eq!(position_marker_frets(6), &[3, 5, 7, 9, 12, 15, 17, 19, 21]);
assert_eq!(position_marker_frets(4), &[3, 5, 7, 10, 12, 15]);
assert!(position_marker_frets(5).is_empty());
assert!(position_marker_frets(7).is_empty());
assert!(position_marker_frets(12).is_empty());
assert!(position_marker_frets(0).is_empty());
}
#[test]
fn test_render_svg_guitar_has_position_markers_for_visible_inlay_frets() {
let data = DiagramData {
name: "C".to_string(),
display_name: None,
strings: 6,
frets_shown: 5,
base_fret: 1,
frets: vec![-1, 3, 2, 0, 1, 0],
fingers: vec![],
};
let svg = render_svg(&data);
assert!(
svg.contains("class=\"position-marker\""),
"expected at least one position-marker dot; got: {svg}"
);
let count = svg.matches("class=\"position-marker\"").count();
assert_eq!(count, 2);
}
#[test]
fn test_render_svg_guitar_position_markers_offset_by_base_fret() {
let data = DiagramData {
name: "Bm".to_string(),
display_name: None,
strings: 6,
frets_shown: 5,
base_fret: 7,
frets: vec![1, 1, 1, 1, 1, 1],
fingers: vec![],
};
let svg = render_svg(&data);
let count = svg.matches("class=\"position-marker\"").count();
assert_eq!(
count, 2,
"expected fret-7 and fret-9 inlays; got svg: {svg}"
);
}
#[test]
fn test_render_svg_guitar_double_dot_at_octave_fret_12() {
let data = DiagramData {
name: "C".to_string(),
display_name: None,
strings: 6,
frets_shown: 5,
base_fret: 9, frets: vec![-1, -1, -1, -1, -1, -1],
fingers: vec![],
};
let svg = render_svg(&data);
let count = svg.matches("class=\"position-marker\"").count();
assert_eq!(
count, 3,
"expected 1 single + 2 (double at 12); got svg: {svg}"
);
}
#[test]
fn test_render_svg_ukulele_position_markers() {
let data = DiagramData {
name: "C".to_string(),
display_name: None,
strings: 4,
frets_shown: 5,
base_fret: 1,
frets: vec![0, 0, 0, 3],
fingers: vec![],
};
let svg = render_svg(&data);
let count = svg.matches("class=\"position-marker\"").count();
assert_eq!(count, 2);
}
#[test]
fn test_from_raw_basic() {
let data = DiagramData::from_raw("Am", "base-fret 1 frets x 0 2 2 1 0", 6).unwrap();
assert_eq!(data.name, "Am");
assert_eq!(data.base_fret, 1);
assert_eq!(data.frets, vec![-1, 0, 2, 2, 1, 0]);
}
#[test]
fn test_from_raw_with_fingers() {
let data =
DiagramData::from_raw("C", "base-fret 1 frets x 3 2 0 1 0 fingers 0 3 2 0 1 0", 6)
.unwrap();
assert_eq!(data.frets, vec![-1, 3, 2, 0, 1, 0]);
assert_eq!(data.fingers, vec![0, 3, 2, 0, 1, 0]);
}
#[test]
fn test_from_raw_no_frets() {
assert!(DiagramData::from_raw("X", "base-fret 1", 6).is_none());
}
#[test]
fn test_from_raw_ukulele() {
let data = DiagramData::from_raw("C", "frets 0 0 0 3", 4).unwrap();
assert_eq!(data.strings, 4);
assert_eq!(data.frets, vec![0, 0, 0, 3]);
}
#[test]
fn test_from_raw_infer_guitar() {
let data = DiagramData::from_raw_infer("Am", "base-fret 1 frets x 0 2 2 1 0").unwrap();
assert_eq!(data.strings, 6);
assert_eq!(data.frets, vec![-1, 0, 2, 2, 1, 0]);
}
#[test]
fn test_from_raw_infer_ukulele() {
let data = DiagramData::from_raw_infer("C", "frets 0 0 0 3").unwrap();
assert_eq!(data.strings, 4);
assert_eq!(data.frets, vec![0, 0, 0, 3]);
}
#[test]
fn test_from_raw_infer_banjo() {
let data = DiagramData::from_raw_infer("G", "frets 0 0 0 0 0").unwrap();
assert_eq!(data.strings, 5);
assert_eq!(data.frets, vec![0, 0, 0, 0, 0]);
}
#[test]
fn test_title_returns_display_name_when_set() {
let data = DiagramData {
name: "Am".to_string(),
display_name: Some("A minor".to_string()),
strings: 6,
frets_shown: 5,
base_fret: 1,
frets: vec![-1, 0, 2, 2, 1, 0],
fingers: vec![],
};
assert_eq!(data.title(), "A minor");
}
#[test]
fn test_title_falls_back_to_name() {
let data = DiagramData {
name: "Am".to_string(),
display_name: None,
strings: 6,
frets_shown: 5,
base_fret: 1,
frets: vec![-1, 0, 2, 2, 1, 0],
fingers: vec![],
};
assert_eq!(data.title(), "Am");
}
#[test]
fn test_render_svg_with_display_name() {
let data = DiagramData {
name: "Am".to_string(),
display_name: Some("A minor".to_string()),
strings: 6,
frets_shown: 5,
base_fret: 1,
frets: vec![-1, 0, 2, 2, 1, 0],
fingers: vec![],
};
let svg = render_svg(&data);
assert!(svg.contains("A minor"));
assert!(!svg.contains(">Am<"));
}
#[test]
fn test_from_raw_display_name_is_none() {
let data = DiagramData::from_raw_infer("Am", "base-fret 1 frets x 0 2 2 1 0").unwrap();
assert!(data.display_name.is_none());
}
#[test]
fn test_render_svg_with_finger_numbers() {
let data = DiagramData {
name: "C".to_string(),
display_name: None,
strings: 6,
frets_shown: 5,
base_fret: 1,
frets: vec![-1, 3, 2, 0, 1, 0],
fingers: vec![0, 3, 2, 0, 1, 0],
};
let svg = render_svg(&data);
assert!(svg.contains("fill=\"white\">3</text>"));
assert!(svg.contains("fill=\"white\">2</text>"));
assert!(svg.contains("fill=\"white\">1</text>"));
}
#[test]
fn test_render_svg_zero_finger_not_shown() {
let data = DiagramData {
name: "Am".to_string(),
display_name: None,
strings: 6,
frets_shown: 5,
base_fret: 1,
frets: vec![-1, 0, 2, 2, 1, 0],
fingers: vec![0, 0, 2, 3, 1, 0],
};
let svg = render_svg(&data);
assert!(!svg.contains("fill=\"white\">0</text>"));
assert!(svg.contains("fill=\"white\">2</text>"));
assert!(svg.contains("fill=\"white\">3</text>"));
assert!(svg.contains("fill=\"white\">1</text>"));
}
#[test]
fn test_render_svg_no_fingers_no_crash() {
let data = DiagramData {
name: "G".to_string(),
display_name: None,
strings: 6,
frets_shown: 5,
base_fret: 1,
frets: vec![3, 2, 0, 0, 0, 3],
fingers: vec![],
};
let svg = render_svg(&data);
assert!(svg.contains("<svg"));
assert!(!svg.contains("fill=\"white\""));
}
#[test]
fn test_render_svg_fewer_fingers_than_frets() {
let data = DiagramData {
name: "Am".to_string(),
display_name: None,
strings: 6,
frets_shown: 5,
base_fret: 1,
frets: vec![-1, 0, 2, 2, 1, 0],
fingers: vec![0, 0, 2],
};
let svg = render_svg(&data);
assert!(svg.contains("fill=\"white\">2</text>"));
assert!(svg.contains("<svg"));
}
#[test]
fn test_from_raw_zero_strings_rejected() {
assert!(DiagramData::from_raw("X", "frets 0", 0).is_none());
}
#[test]
fn test_from_raw_one_string_rejected() {
assert!(DiagramData::from_raw("X", "frets 0", 1).is_none());
}
#[test]
fn test_from_raw_two_strings_accepted() {
let data = DiagramData::from_raw("X", "frets 0 0", 0).unwrap();
assert_eq!(data.strings, 2);
}
#[test]
fn test_from_raw_twelve_strings_accepted() {
let data = DiagramData::from_raw("X", "frets 0 0 0 0 0 0 0 0 0 0 0 0", 0).unwrap();
assert_eq!(data.strings, 12);
}
#[test]
fn test_from_raw_thirteen_strings_rejected() {
assert!(DiagramData::from_raw("X", "frets 0 0 0 0 0 0 0 0 0 0 0 0 0", 0,).is_none());
}
#[test]
fn test_from_raw_num_strings_forces_too_many() {
assert!(DiagramData::from_raw("X", "frets 0 0 0 0 0 0", 13).is_none());
}
#[test]
fn test_fret_exceeding_range_clamped() {
let data = DiagramData::from_raw("X", "base-fret 1 frets 0 12 0 0 0 0", 6).unwrap();
assert_eq!(data.frets[1], 5);
}
#[test]
fn test_fret_at_boundary_not_clamped() {
let data = DiagramData::from_raw("X", "base-fret 1 frets 0 5 0 0 0 0", 6).unwrap();
assert_eq!(data.frets[1], 5);
}
#[test]
fn test_fret_within_range_unchanged() {
let data = DiagramData::from_raw("X", "base-fret 1 frets 0 3 0 0 0 0", 6).unwrap();
assert_eq!(data.frets[1], 3);
}
#[test]
fn test_muted_and_open_not_clamped() {
let data = DiagramData::from_raw("X", "frets x 0 1 2 3 4", 6).unwrap();
assert_eq!(data.frets[0], -1); assert_eq!(data.frets[1], 0); }
#[test]
fn test_extreme_fret_value_clamped() {
let data = DiagramData::from_raw("X", "frets 1000 0 0 0 0 0", 6).unwrap();
assert_eq!(data.frets[0], 5);
}
#[test]
fn test_seven_string_instrument() {
let data = DiagramData::from_raw("X", "frets 0 0 0 0 0 0 0", 7).unwrap();
assert_eq!(data.strings, 7);
let svg = render_svg(&data);
assert!(svg.contains("<svg"));
}
#[test]
fn test_eight_string_instrument() {
let data = DiagramData::from_raw("X", "frets 0 0 0 0 0 0 0 0", 8).unwrap();
assert_eq!(data.strings, 8);
let svg = render_svg(&data);
assert!(svg.contains("<svg"));
}
#[test]
fn test_twelve_string_renders_without_panic() {
let data = DiagramData::from_raw("G12", "frets 0 0 0 0 0 0 0 0 0 0 0 0", 12).unwrap();
let svg = render_svg(&data);
assert!(svg.contains("G12"));
}
#[test]
fn test_fewer_fingers_than_frets() {
let data = DiagramData::from_raw("Am", "frets x 0 2 2 1 0 fingers 0 0 2", 6).unwrap();
assert_eq!(data.frets.len(), 6);
assert_eq!(data.fingers.len(), 3);
let svg = render_svg(&data);
assert!(svg.contains("<svg"));
}
#[test]
fn test_empty_frets_rejected() {
assert!(DiagramData::from_raw("X", "base-fret 1", 6).is_none());
}
#[test]
fn test_non_numeric_fret_treated_as_muted() {
let data = DiagramData::from_raw("X", "frets abc 0 0 0 0 0", 6).unwrap();
assert_eq!(data.frets[0], -1);
}
#[test]
fn test_extreme_base_fret() {
let data = DiagramData::from_raw("X", "base-fret 1000 frets 1 2 3 4 5 6", 6).unwrap();
assert_eq!(data.base_fret, 24);
let svg = render_svg(&data);
assert!(svg.contains(">24</text>"));
assert!(!svg.contains("24fr"));
}
#[test]
fn test_all_muted_strings() {
let data = DiagramData::from_raw("X", "frets x x x x x x", 6).unwrap();
assert!(data.frets.iter().all(|&f| f == -1));
let svg = render_svg(&data);
assert!(svg.contains("<svg"));
}
#[test]
fn test_all_open_strings() {
let data = DiagramData::from_raw("Open", "frets 0 0 0 0 0 0", 6).unwrap();
assert!(data.frets.iter().all(|&f| f == 0));
let svg = render_svg(&data);
assert!(svg.contains("<svg"));
}
#[test]
fn test_format_stops_fret_parsing() {
let data = DiagramData::from_raw("Am", "base-fret 1 frets x 0 2 2 1 0 format", 6).unwrap();
assert_eq!(data.frets, vec![-1, 0, 2, 2, 1, 0]);
}
#[test]
fn test_base_fret_after_frets_is_stop_word() {
let data = DiagramData::from_raw("Am", "frets x 0 2 2 1 0 base-fret 3", 6).unwrap();
assert_eq!(data.frets, vec![-1, 0, 2, 2, 1, 0]);
assert_eq!(data.base_fret, 3);
}
#[test]
fn test_base_fret_after_fingers_is_stop_word() {
let data =
DiagramData::from_raw("C", "frets x 3 2 0 1 0 fingers 0 3 2 0 1 0 base-fret 2", 6)
.unwrap();
assert_eq!(data.fingers, vec![0, 3, 2, 0, 1, 0]);
assert_eq!(data.base_fret, 2);
}
#[test]
fn test_format_stops_finger_parsing() {
let data =
DiagramData::from_raw("C", "frets x 3 2 0 1 0 fingers 0 3 2 0 1 0 format", 6).unwrap();
assert_eq!(data.fingers, vec![0, 3, 2, 0, 1, 0]);
}
#[test]
fn test_base_fret_zero_clamped_to_one() {
let data = DiagramData::from_raw("Am", "base-fret 0 frets x 0 2 2 1 0", 6).unwrap();
assert_eq!(data.base_fret, 1);
}
#[test]
fn test_base_fret_negative_defaults_to_one() {
let data = DiagramData::from_raw("Am", "base-fret -1 frets x 0 2 2 1 0", 6).unwrap();
assert_eq!(data.base_fret, 1);
}
#[test]
fn test_base_fret_large_value_clamped() {
let data = DiagramData::from_raw("Am", "base-fret 100 frets x 0 2 2 1 0", 6).unwrap();
assert_eq!(data.base_fret, 24);
}
#[test]
fn test_render_svg_zero_strings_returns_empty() {
let data = DiagramData {
name: "X".to_string(),
display_name: None,
strings: 0,
frets_shown: 5,
base_fret: 1,
frets: vec![],
fingers: vec![],
};
assert!(render_svg(&data).is_empty());
}
#[test]
fn test_render_svg_one_string_returns_empty() {
let data = DiagramData {
name: "X".to_string(),
display_name: None,
strings: 1,
frets_shown: 5,
base_fret: 1,
frets: vec![0],
fingers: vec![],
};
assert!(render_svg(&data).is_empty());
}
#[test]
fn test_frets_stops_finger_parsing() {
let data =
DiagramData::from_raw("Am", "frets x 0 2 2 1 0 fingers 0 0 2 frets x 0 2 2 1 0", 6)
.unwrap();
assert_eq!(data.fingers, vec![0, 0, 2]);
assert_eq!(data.frets.len(), 12);
assert_eq!(data.strings, 12);
}
#[test]
fn test_repeated_frets_keyword_not_consumed_as_value() {
let data = DiagramData::from_raw("Am", "frets 1 2 3 frets 4 5 6", 0).unwrap();
assert_eq!(data.frets.len(), 6);
assert!(!data.frets.contains(&-1));
}
#[test]
fn test_from_raw_frets_custom_frets_shown() {
let data =
DiagramData::from_raw_frets("Am", "base-fret 1 frets x 0 2 2 1 0", 6, 4).unwrap();
assert_eq!(data.frets_shown, 4);
}
#[test]
fn test_from_raw_infer_frets_custom_frets_shown() {
let data =
DiagramData::from_raw_infer_frets("Am", "base-fret 1 frets x 0 2 2 1 0", 3).unwrap();
assert_eq!(data.frets_shown, 3);
}
#[test]
fn test_from_raw_frets_clamps_fret_values() {
let data =
DiagramData::from_raw_frets("Am", "base-fret 1 frets 0 5 0 0 0 0", 6, 3).unwrap();
assert_eq!(data.frets[1], 3);
}
#[test]
fn test_render_svg_zero_frets_shown_returns_empty() {
let data = DiagramData {
name: "X".to_string(),
display_name: None,
strings: 6,
frets_shown: 0,
base_fret: 1,
frets: vec![0, 0, 0, 0, 0, 0],
fingers: vec![],
};
assert!(render_svg(&data).is_empty());
}
#[test]
fn test_render_svg_exceeding_max_strings_returns_empty() {
let data = DiagramData {
name: "X".to_string(),
display_name: None,
strings: 13,
frets_shown: 5,
base_fret: 1,
frets: vec![0; 13],
fingers: vec![],
};
assert!(render_svg(&data).is_empty());
}
#[test]
fn test_render_svg_at_max_strings_ok() {
let data = DiagramData {
name: "X".to_string(),
display_name: None,
strings: MAX_STRINGS,
frets_shown: 5,
base_fret: 1,
frets: vec![0; MAX_STRINGS],
fingers: vec![],
};
assert!(!render_svg(&data).is_empty());
}
#[test]
fn test_render_svg_exceeding_max_frets_shown_returns_empty() {
let data = DiagramData {
name: "X".to_string(),
display_name: None,
strings: 6,
frets_shown: MAX_FRETS_SHOWN + 1,
base_fret: 1,
frets: vec![0; 6],
fingers: vec![],
};
assert!(render_svg(&data).is_empty());
}
#[test]
fn test_render_svg_at_max_frets_shown_ok() {
let data = DiagramData {
name: "X".to_string(),
display_name: None,
strings: 6,
frets_shown: MAX_FRETS_SHOWN,
base_fret: 1,
frets: vec![0; 6],
fingers: vec![],
};
assert!(!render_svg(&data).is_empty());
}
#[test]
fn test_from_raw_frets_clamps_excessive_frets_shown() {
let data = DiagramData::from_raw_frets("Am", "frets x 0 2 2 1 0", 6, 100).unwrap();
assert_eq!(data.frets_shown, MAX_FRETS_SHOWN);
}
#[test]
fn test_from_raw_frets_clamps_zero_frets_shown() {
let data = DiagramData::from_raw_frets("Am", "frets x 0 2 2 1 0", 6, 0).unwrap();
assert_eq!(data.frets_shown, MIN_FRETS_SHOWN);
}
#[test]
fn test_duplicate_fingers_keyword_is_stop_word() {
let data = DiagramData::from_raw("Am", "frets x 0 2 2 1 0 fingers 0 0 2 fingers 0 0 2", 6)
.unwrap();
assert_eq!(data.fingers.len(), 6);
assert_eq!(data.fingers, vec![0, 0, 2, 0, 0, 2]);
}
#[test]
fn test_finger_overflow_beyond_u8_max_stops_parsing() {
let data =
DiagramData::from_raw("Am", "frets x 0 2 2 1 0 fingers 256 1 2 3 1 0", 6).unwrap();
assert!(
data.fingers.is_empty(),
"invalid finger token must stop parsing, not silently become 0: {:?}",
data.fingers
);
}
#[test]
fn test_finger_garbage_token_stops_parsing() {
let data =
DiagramData::from_raw("Am", "frets x 0 2 2 1 0 fingers abc 1 2 3 1 0", 6).unwrap();
assert!(
data.fingers.is_empty(),
"garbage finger token must stop parsing: {:?}",
data.fingers
);
}
#[test]
fn test_valid_fingers_before_invalid_are_kept() {
let data =
DiagramData::from_raw("Am", "frets x 0 2 2 1 0 fingers 0 3 2 abc 1 0", 6).unwrap();
assert_eq!(data.fingers, vec![0, 3, 2]);
}
#[test]
fn test_negative_fret_below_minus_one_clamped() {
let data = DiagramData::from_raw("X", "frets -5 0 2 2 1 0", 6).unwrap();
assert_eq!(data.frets[0], -1);
}
#[test]
fn test_minus_one_fret_unchanged() {
let data = DiagramData::from_raw("X", "frets -1 0 2 2 1 0", 6).unwrap();
assert_eq!(data.frets[0], -1);
}
#[test]
fn test_large_negative_fret_clamped() {
let data = DiagramData::from_raw("X", "frets -100 0 0 0 0 0", 6).unwrap();
assert_eq!(data.frets[0], -1);
}
#[test]
fn test_mixed_case_keywords() {
let data =
DiagramData::from_raw("Am", "Base-Fret 3 Frets x 0 2 2 1 0 Fingers 0 0 2 3 1 0", 6)
.unwrap();
assert_eq!(data.base_fret, 3);
assert_eq!(data.frets, vec![-1, 0, 2, 2, 1, 0]);
assert_eq!(data.fingers, vec![0, 0, 2, 3, 1, 0]);
}
#[test]
fn test_uppercase_keywords() {
let data =
DiagramData::from_raw("Am", "BASE-FRET 2 FRETS X 0 2 2 1 0 FINGERS 0 0 2 3 1 0", 6)
.unwrap();
assert_eq!(data.base_fret, 2);
assert_eq!(data.frets, vec![-1, 0, 2, 2, 1, 0]);
assert_eq!(data.fingers, vec![0, 0, 2, 3, 1, 0]);
}
#[test]
fn test_mixed_case_display_stop_word() {
let data = DiagramData::from_raw("Am", "frets x 0 2 2 1 0 Display", 6).unwrap();
assert_eq!(data.frets, vec![-1, 0, 2, 2, 1, 0]);
}
#[test]
fn test_mixed_case_format_stop_word() {
let data = DiagramData::from_raw("Am", "frets x 0 2 2 1 0 Format", 6).unwrap();
assert_eq!(data.frets, vec![-1, 0, 2, 2, 1, 0]);
}
#[test]
fn test_fewer_fret_values_than_num_strings() {
let data = DiagramData::from_raw("X", "frets 1 2 3", 6).unwrap();
assert_eq!(data.strings, 6);
assert_eq!(data.frets.len(), 3);
let svg = render_svg(&data);
assert!(svg.contains("<svg"));
}
#[test]
fn test_fewer_fret_values_inferred() {
let data = DiagramData::from_raw_infer("X", "frets 1 2 3").unwrap();
assert_eq!(data.strings, 3);
assert_eq!(data.frets.len(), 3);
}
#[test]
fn test_render_keyboard_svg_absolute_midi() {
let v = KeyboardVoicing {
name: "Cmaj7".to_string(),
display_name: None,
keys: vec![60, 64, 67, 71],
root_key: 60,
};
let svg = render_keyboard_svg(&v);
assert!(svg.contains("<svg"));
assert!(svg.contains("</svg>"));
assert!(svg.contains("Cmaj7"));
assert!(svg.contains("class=\"keyboard-diagram\""));
assert!(svg.contains("#4a90e2") || svg.contains("#1a5fb4"));
}
#[test]
fn test_render_keyboard_svg_pitch_classes_normalised_to_octave4() {
let v = KeyboardVoicing {
name: "Am".to_string(),
display_name: None,
keys: vec![0, 3, 7],
root_key: 0,
};
let svg = render_keyboard_svg(&v);
assert!(svg.contains("<svg"));
assert!(svg.contains("Am"));
}
#[test]
fn test_render_keyboard_svg_empty_keys_returns_empty() {
let v = KeyboardVoicing {
name: "X".to_string(),
display_name: None,
keys: vec![],
root_key: 60,
};
assert_eq!(render_keyboard_svg(&v), "");
}
#[test]
fn test_render_keyboard_svg_display_name_override() {
let v = KeyboardVoicing {
name: "Am".to_string(),
display_name: Some("A minor".to_string()),
keys: vec![69, 72, 76],
root_key: 69,
};
let svg = render_keyboard_svg(&v);
assert!(svg.contains("A minor"));
assert!(!svg.contains(">Am<"));
}
#[test]
fn test_keyboard_voicing_title() {
let v = KeyboardVoicing {
name: "G".to_string(),
display_name: Some("G major".to_string()),
keys: vec![67, 71, 74],
root_key: 67,
};
assert_eq!(v.title(), "G major");
let v2 = KeyboardVoicing {
name: "G".to_string(),
display_name: None,
keys: vec![67, 71, 74],
root_key: 67,
};
assert_eq!(v2.title(), "G");
}
#[test]
fn test_resolve_diagrams_instrument_piano() {
assert_eq!(
resolve_diagrams_instrument(Some("piano"), "guitar"),
Some("piano".to_string())
);
assert_eq!(
resolve_diagrams_instrument(Some("keys"), "guitar"),
Some("piano".to_string())
);
assert_eq!(
resolve_diagrams_instrument(Some("keyboard"), "guitar"),
Some("piano".to_string())
);
}
#[test]
fn normalise_keyboard_keys_empty_slice_shifts_pitch_class_root() {
let (keys_out, root_out) = normalise_keyboard_keys(&[], 0);
assert!(keys_out.is_empty());
assert_eq!(
root_out, 60,
"pitch-class root 0 should be shifted to C4 (60)"
);
let (keys_out2, root_out2) = normalise_keyboard_keys(&[], 9);
assert!(keys_out2.is_empty());
assert_eq!(
root_out2, 69,
"pitch-class root 9 should be shifted to A4 (69)"
);
}
#[test]
fn normalise_keyboard_keys_empty_slice_absolute_root_unchanged() {
let (keys_out, root_out) = normalise_keyboard_keys(&[], 60);
assert!(keys_out.is_empty());
assert_eq!(root_out, 60, "absolute root_key must not be shifted");
}
}