1use super::Fraction;
29use crate::defaults::{FloatType, IntegerType, UnsignedIntegerType};
30use crate::error::{Error, Result};
31
32use std::collections::BTreeMap;
33use std::fmt::{Display, Formatter};
34use std::str::FromStr;
35
36const CENTS_PER_OCTAVE: FloatType = 1200.0;
38
39#[derive(Clone, Copy, Debug, PartialEq)]
45#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
46#[must_use]
47pub enum ScalaDegree {
48 Ratio(Fraction),
50 Cents(FloatType),
52}
53
54impl ScalaDegree {
55 pub fn ratio(self) -> FloatType {
57 match self {
58 Self::Ratio(fraction) => fraction.ratio(),
59 Self::Cents(cents) => (2.0 as FloatType).powf(cents / CENTS_PER_OCTAVE),
60 }
61 }
62
63 pub fn cents(self) -> FloatType {
65 match self {
66 Self::Ratio(fraction) => CENTS_PER_OCTAVE * fraction.ratio().log2(),
67 Self::Cents(cents) => cents,
68 }
69 }
70
71 pub fn as_fraction(self) -> Option<Fraction> {
73 match self {
74 Self::Ratio(fraction) => Some(fraction),
75 Self::Cents(_) => None,
76 }
77 }
78
79 fn parse(token: &str) -> Result<Self> {
81 let token = token.split('!').next().unwrap_or_default();
85 let token = token.split_whitespace().next().unwrap_or_default();
86 if token.is_empty() {
87 return Err(Error::TuningSystem("empty scala degree".to_string()));
88 }
89
90 if let Some((steps, divisions)) = token.split_once('\\') {
93 let steps: FloatType = steps.trim().parse().map_err(|_| {
94 Error::TuningSystem(format!("invalid scala step count in {token:?}"))
95 })?;
96 let divisions: FloatType = divisions.trim().parse().map_err(|_| {
97 Error::TuningSystem(format!("invalid scala division count in {token:?}"))
98 })?;
99 if divisions == 0.0 {
100 return Err(Error::TuningSystem(format!(
101 "scala degree {token:?} divides the octave into zero steps"
102 )));
103 }
104 return Ok(Self::Cents(CENTS_PER_OCTAVE * steps / divisions));
105 }
106
107 if token.contains('.') {
108 let cents: FloatType = token
109 .parse()
110 .map_err(|_| Error::TuningSystem(format!("invalid scala cents value {token:?}")))?;
111 if !cents.is_finite() {
112 return Err(Error::TuningSystem(format!(
113 "scala cents value {token:?} is not finite"
114 )));
115 }
116 return Ok(Self::Cents(cents));
117 }
118
119 let (numerator, denominator) = match token.split_once('/') {
120 Some((numerator, denominator)) => (numerator.trim(), denominator.trim()),
121 None => (token, "1"),
122 };
123
124 if let (Ok(numerator), Ok(denominator)) = (
125 numerator.parse::<UnsignedIntegerType>(),
126 denominator.parse::<UnsignedIntegerType>(),
127 ) && numerator != 0
128 && denominator != 0
129 {
130 return Ok(Self::Ratio(Fraction::new(numerator, denominator)));
131 }
132
133 let (numerator, denominator) = (
139 parse_ratio_term(numerator, token, "numerator")?,
140 parse_ratio_term(denominator, token, "denominator")?,
141 );
142 if numerator <= 0.0 || denominator <= 0.0 {
143 return Err(Error::TuningSystem(format!(
144 "scala ratio {token:?} must be positive"
145 )));
146 }
147
148 Ok(Self::Cents(
149 CENTS_PER_OCTAVE * (numerator / denominator).log2(),
150 ))
151 }
152}
153
154fn parse_ratio_term(term: &str, token: &str, side: &str) -> Result<FloatType> {
156 if term.is_empty() || !term.bytes().all(|byte| byte.is_ascii_digit()) {
157 return Err(Error::TuningSystem(format!(
158 "invalid scala ratio {side} in {token:?}"
159 )));
160 }
161 term.parse()
162 .map_err(|_| Error::TuningSystem(format!("invalid scala ratio {side} in {token:?}")))
163}
164
165impl Display for ScalaDegree {
166 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
167 match self {
168 Self::Ratio(fraction) => write!(f, "{fraction}"),
169 Self::Cents(cents) => write!(f, "{cents}"),
170 }
171 }
172}
173
174#[derive(Clone, Debug, PartialEq)]
182#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
183#[must_use]
184pub struct ScalaScale {
185 description: String,
186 degrees: Vec<ScalaDegree>,
187 period: ScalaDegree,
188}
189
190impl ScalaScale {
191 pub fn new(
194 description: impl Into<String>,
195 degrees: Vec<ScalaDegree>,
196 period: ScalaDegree,
197 ) -> Self {
198 Self {
199 description: description.into(),
200 degrees,
201 period,
202 }
203 }
204
205 #[cfg(feature = "scala-archive")]
207 pub(crate) fn from_parts(
208 description: String,
209 degrees: Vec<ScalaDegree>,
210 period: ScalaDegree,
211 ) -> Self {
212 Self::new(description, degrees, period)
213 }
214
215 pub fn parse(contents: &str) -> Result<Self> {
221 let lines: Vec<&str> = contents
222 .lines()
223 .map(str::trim)
224 .filter(|line| !line.starts_with('!'))
225 .collect();
226
227 let count_line = lines
228 .get(1)
229 .ok_or_else(|| Error::TuningSystem("scala file has no degree count".to_string()))?;
230 let count: usize = count_line
231 .split_whitespace()
232 .next()
233 .unwrap_or_default()
234 .parse()
235 .map_err(|_| {
236 Error::TuningSystem(format!("invalid scala degree count {count_line:?}"))
237 })?;
238 let entries: Vec<&str> = lines
239 .iter()
240 .skip(2)
241 .filter(|line| !line.is_empty())
242 .take(count)
243 .copied()
244 .collect();
245 if entries.len() != count {
246 return Err(Error::TuningSystem(format!(
247 "scala file declares {count} degrees but lists {}",
248 entries.len()
249 )));
250 }
251
252 let mut parsed = Vec::with_capacity(count);
253 for entry in entries {
254 parsed.push(ScalaDegree::parse(entry)?);
255 }
256
257 let (mut degrees, period) = match parsed.pop() {
261 Some(period) => (vec![ScalaDegree::Ratio(Fraction::new(1, 1))], period),
262 None => (Vec::new(), ScalaDegree::Ratio(Fraction::new(1, 1))),
263 };
264 degrees.append(&mut parsed);
265
266 Ok(Self {
267 description: lines.first().unwrap_or(&"").trim().to_string(),
268 degrees,
269 period,
270 })
271 }
272
273 pub fn parse_bytes(bytes: &[u8]) -> Result<Self> {
292 let text: String = bytes.iter().map(|&byte| byte as char).collect();
294 Self::parse(&text)
295 }
296
297 pub fn description(&self) -> &str {
299 &self.description
300 }
301
302 pub fn degrees(&self) -> &[ScalaDegree] {
304 &self.degrees
305 }
306
307 pub fn period(&self) -> ScalaDegree {
312 self.period
313 }
314
315 pub fn len(&self) -> usize {
317 self.degrees.len()
318 }
319
320 pub fn is_empty(&self) -> bool {
325 self.degrees.is_empty()
326 }
327
328 pub fn ratio_at(&self, index: IntegerType) -> FloatType {
336 if self.degrees.is_empty() {
337 return 1.0;
338 }
339 let len = self.degrees.len() as IntegerType;
340 let periods = index.div_euclid(len);
341 let degree = index.rem_euclid(len) as usize;
342 self.degrees[degree].ratio() * self.period.ratio().powi(periods)
343 }
344
345 pub fn cents_above_root(&self, index: IntegerType) -> FloatType {
351 CENTS_PER_OCTAVE * self.ratio_at(index).log2()
352 }
353
354 pub fn frequency_at(&self, root_hz: FloatType, index: IntegerType) -> FloatType {
359 root_hz * self.ratio_at(index)
360 }
361}
362
363impl FromStr for ScalaScale {
364 type Err = Error;
365
366 fn from_str(contents: &str) -> Result<Self> {
367 Self::parse(contents)
368 }
369}
370
371impl Display for ScalaScale {
372 fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
373 write!(f, "{} ({} degrees)", self.description, self.degrees.len())
374 }
375}
376
377#[derive(Clone, Debug, Default, PartialEq)]
397#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
398#[must_use]
399pub struct ScalaArchive {
400 scales: BTreeMap<String, ScalaScale>,
401}
402
403impl ScalaArchive {
404 pub fn new() -> Self {
406 Self::default()
407 }
408
409 pub fn insert(
414 &mut self,
415 file_name: impl Into<String>,
416 bytes: &[u8],
417 ) -> Result<Option<ScalaScale>> {
418 let file_name = file_name.into();
419 let scale = ScalaScale::parse_bytes(bytes)
420 .map_err(|error| Error::TuningSystem(format!("{file_name}: {error}")))?;
421 Ok(self.scales.insert(file_name, scale))
422 }
423
424 pub fn insert_scale(
426 &mut self,
427 file_name: impl Into<String>,
428 scale: ScalaScale,
429 ) -> Option<ScalaScale> {
430 self.scales.insert(file_name.into(), scale)
431 }
432
433 pub fn get(&self, file_name: &str) -> Option<&ScalaScale> {
435 self.scales.get(file_name)
436 }
437
438 pub fn names(&self) -> impl Iterator<Item = &str> {
440 self.scales.keys().map(String::as_str)
441 }
442
443 pub fn iter(&self) -> impl Iterator<Item = (&str, &ScalaScale)> {
445 self.scales
446 .iter()
447 .map(|(name, scale)| (name.as_str(), scale))
448 }
449
450 pub fn len(&self) -> usize {
452 self.scales.len()
453 }
454
455 pub fn is_empty(&self) -> bool {
457 self.scales.is_empty()
458 }
459
460 #[cfg(feature = "scala-archive")]
469 pub fn bundled() -> Self {
470 Self::bundled_with_failures().0
471 }
472
473 #[cfg(feature = "scala-archive")]
478 pub fn bundled_with_failures() -> (Self, Vec<(&'static str, Error)>) {
479 let mut archive = Self::new();
480 let mut failures = Vec::new();
481 for (file_name, description, degrees, period) in crate::tuningsystem::scala_bundled::SCALES
482 {
483 match build_bundled(description, degrees, period) {
484 Ok(scale) => {
485 archive.insert_scale(file_name, scale);
486 }
487 Err(error) => failures.push((file_name, error)),
488 }
489 }
490 (archive, failures)
491 }
492
493 #[cfg(feature = "scala-archive")]
498 pub fn bundled_len() -> usize {
499 crate::tuningsystem::scala_bundled::SCALES.len()
500 }
501
502 pub fn search(&self, target: &str) -> Vec<&str> {
510 let target = target.replace(' ', "").to_lowercase();
511 let mut matches = Vec::new();
512
513 for name in self.scales.keys() {
514 if name.to_lowercase() == target {
515 matches.push(name.as_str());
516 }
517 }
518
519 for name in self.scales.keys() {
520 if matches.contains(&name.as_str()) {
521 continue;
522 }
523 let stem = name.strip_suffix(".scl").unwrap_or(name).to_lowercase();
524 let squashed = stem.replace(['_', '-'], "");
525 if stem.contains(&target) || squashed.contains(&target) {
526 matches.push(name.as_str());
527 }
528 }
529
530 matches.sort_unstable();
531 matches
532 }
533}
534
535#[cfg(feature = "scala-archive")]
540fn build_bundled(description: &str, degrees: &[&str], period: &str) -> Result<ScalaScale> {
541 let degrees = degrees
542 .iter()
543 .map(|token| ScalaDegree::parse(token))
544 .collect::<Result<Vec<_>>>()?;
545 Ok(ScalaScale::from_parts(
546 description.to_string(),
547 degrees,
548 ScalaDegree::parse(period)?,
549 ))
550}
551
552impl Extend<(String, ScalaScale)> for ScalaArchive {
553 fn extend<T: IntoIterator<Item = (String, ScalaScale)>>(&mut self, iter: T) {
554 self.scales.extend(iter);
555 }
556}
557
558impl FromIterator<(String, ScalaScale)> for ScalaArchive {
559 fn from_iter<T: IntoIterator<Item = (String, ScalaScale)>>(iter: T) -> Self {
560 Self {
561 scales: iter.into_iter().collect(),
562 }
563 }
564}
565
566#[cfg(all(test, feature = "scala-archive"))]
567mod bundled_tests {
568 use super::ScalaArchive;
569
570 #[test]
571 fn the_whole_bundled_archive_parses() {
572 let (archive, failures) = ScalaArchive::bundled_with_failures();
573 let names: Vec<&str> = failures.iter().map(|(name, _)| *name).collect();
574 assert_eq!(names, [] as [&str; 0]);
577 assert_eq!(archive.len(), ScalaArchive::bundled_len());
578 assert_eq!(ScalaArchive::bundled_len(), 3994);
580 }
581
582 #[test]
583 fn the_bundled_archive_carries_the_scales_the_tuning_tables_cite() {
584 let archive = ScalaArchive::bundled();
585 for name in [
586 "partch_43.scl",
587 "partch_29.scl",
588 "werck3.scl",
589 "vallotti.scl",
590 "meanquar.scl",
591 "ptolemy.scl",
592 "pyth_12.scl",
593 "kirnberger3.scl",
594 "rameau.scl",
595 "young2.scl",
596 "carlos_harm.scl",
597 "riley_albion.scl",
598 "indian.scl",
599 "indian-sagrama.scl",
600 ] {
601 assert!(
602 archive.get(name).is_some(),
603 "{name} missing from the bundle"
604 );
605 }
606 }
607
608 #[test]
609 fn bundled_file_names_are_sorted_and_unique() {
610 let names: Vec<&str> = crate::tuningsystem::scala_bundled::SCALES
611 .iter()
612 .map(|(name, _, _, _)| *name)
613 .collect();
614 let mut sorted = names.clone();
615 sorted.sort_unstable();
616 sorted.dedup();
617 assert_eq!(names, sorted, "bundled file list must be sorted and unique");
618 }
619}
620
621#[cfg(test)]
622mod tests {
623 #[test]
624 fn an_archive_is_built_from_scales_and_walked() {
625 use super::{ScalaArchive, ScalaDegree, ScalaScale};
626
627 let text = "! five.scl\nFive equal steps\n 5\n 240.0\n 480.0\n 720.0\n 960.0\n 2/1\n";
628 let scale = ScalaScale::parse(text).unwrap();
629 assert_eq!(scale.to_string(), "Five equal steps (5 degrees)");
630 assert_eq!(scale.degrees()[1].to_string(), "240");
631 assert_eq!(scale.period().to_string(), "2");
632
633 let mut archive = ScalaArchive::new();
634 assert!(archive.is_empty());
635 assert!(archive.insert_scale("five.scl", scale.clone()).is_none());
636 assert!(archive.insert_scale("five.scl", scale.clone()).is_some());
637 archive.extend([("six.scl".to_string(), scale.clone())]);
638 assert_eq!(archive.len(), 2);
639 let names: Vec<&str> = archive.iter().map(|(name, _)| name).collect();
640 assert!(names.contains(&"five.scl") && names.contains(&"six.scl"));
641 let collected: ScalaArchive = [("one.scl".to_string(), scale)].into_iter().collect();
642 assert_eq!(collected.len(), 1);
643 assert!(!collected.is_empty());
644
645 assert!(ScalaScale::parse("Bad\n 1\n 3//2\n").is_err());
646 assert!(matches!(
647 ScalaScale::parse("Cents\n 1\n 701.955\n").unwrap().period(),
648 ScalaDegree::Cents(_)
649 ));
650 }
651
652 #[test]
653 fn accepts_a_scale_declaring_zero_degrees() {
654 let scale = ScalaScale::parse(
657 "! xxx.scl
658!
659Saved scale from Scala
660 0
661!
662",
663 )
664 .expect("a zero-degree file parses");
665 assert!(scale.is_empty());
666 assert_eq!(scale.len(), 0);
667 assert_eq!(scale.description(), "Saved scale from Scala");
668 assert_eq!(scale.ratio_at(0), 1.0);
670 assert_eq!(scale.ratio_at(7), 1.0);
671 assert_eq!(scale.ratio_at(-3), 1.0);
672 }
673
674 use super::*;
675
676 const FIFTH_AND_OCTAVE: &str = "! example.scl\n!\nA fifth and an octave\n 2\n!\n 3/2\n 2/1\n";
677
678 #[test]
679 fn parses_ratios_into_the_crate_convention() {
680 let scale = ScalaScale::parse(FIFTH_AND_OCTAVE).unwrap();
681 assert_eq!(scale.description(), "A fifth and an octave");
682 assert_eq!(scale.len(), 2);
683 assert_eq!(
684 scale.degrees(),
685 &[
686 ScalaDegree::Ratio(Fraction::new(1, 1)),
687 ScalaDegree::Ratio(Fraction::new(3, 2)),
688 ]
689 );
690 assert_eq!(scale.period(), ScalaDegree::Ratio(Fraction::new(2, 1)));
691 }
692
693 #[test]
694 fn wraps_indices_by_the_period_in_both_directions() {
695 let scale = ScalaScale::parse(FIFTH_AND_OCTAVE).unwrap();
696 assert_eq!(scale.ratio_at(0), 1.0);
697 assert_eq!(scale.ratio_at(1), 1.5);
698 assert_eq!(scale.ratio_at(2), 2.0);
699 assert_eq!(scale.ratio_at(3), 3.0);
700 assert_eq!(scale.ratio_at(-2), 0.5);
701 assert_eq!(scale.ratio_at(-1), 0.75);
702 }
703
704 #[test]
705 fn honours_a_non_octave_period() {
706 let scale = ScalaScale::parse("Tritave\n 2\n 5/3\n 3/1\n").unwrap();
708 assert_eq!(scale.period(), ScalaDegree::Ratio(Fraction::new(3, 1)));
709 assert_eq!(scale.ratio_at(2), 3.0);
710 assert_eq!(scale.ratio_at(4), 9.0);
711 }
712
713 #[test]
714 fn accepts_cents_degrees() {
715 let scale = ScalaScale::parse("Cents\n 2\n 701.955\n 1200.0\n").unwrap();
716 assert_eq!(scale.degrees()[1], ScalaDegree::Cents(701.955));
717 assert!((scale.ratio_at(1) - 1.5).abs() < 1e-6);
718 assert!((scale.ratio_at(2) - 2.0).abs() < 1e-9);
719 }
720
721 #[test]
722 fn mixes_ratio_and_cents_degrees() {
723 let scale = ScalaScale::parse("Mixed\n 3\n 100.0\n 3/2\n 2/1\n").unwrap();
724 assert!(matches!(scale.degrees()[1], ScalaDegree::Cents(_)));
725 assert!(matches!(scale.degrees()[2], ScalaDegree::Ratio(_)));
726 assert!((scale.cents_above_root(2) - 701.955).abs() < 1e-3);
727 }
728
729 #[test]
730 fn treats_a_bare_integer_as_a_whole_ratio() {
731 let scale = ScalaScale::parse("Integers\n 2\n 3\n 4\n").unwrap();
732 assert_eq!(scale.degrees()[1], ScalaDegree::Ratio(Fraction::new(3, 1)));
733 assert_eq!(scale.period(), ScalaDegree::Ratio(Fraction::new(4, 1)));
734 }
735
736 #[test]
737 fn ignores_trailing_comments_on_degree_lines() {
738 let scale = ScalaScale::parse("Commented\n 2\n 3/2 the fifth\n 2/1 octave\n").unwrap();
739 assert_eq!(scale.ratio_at(1), 1.5);
740 }
741
742 #[test]
743 fn ignores_a_bang_comment_with_no_leading_space() {
744 let scale = ScalaScale::parse("Bang\n 2\n 3/2!the fifth\n 2/1!octave\n").unwrap();
746 assert_eq!(scale.ratio_at(1), 1.5);
747 assert_eq!(scale.period(), ScalaDegree::Ratio(Fraction::new(2, 1)));
748 }
749
750 #[test]
751 fn keeps_an_empty_description_line() {
752 let scale = ScalaScale::parse("\n 1\n 2/1\n").unwrap();
753 assert_eq!(scale.description(), "");
754 assert_eq!(scale.len(), 1);
755 }
756
757 #[test]
758 fn computes_frequencies_from_a_caller_supplied_root() {
759 let scale = ScalaScale::parse(FIFTH_AND_OCTAVE).unwrap();
760 assert!((scale.frequency_at(440.0, 1) - 660.0).abs() < 1e-9);
761 assert!((scale.frequency_at(440.0, 2) - 880.0).abs() < 1e-9);
762 }
763
764 #[test]
765 fn falls_back_to_cents_for_ratios_too_wide_for_u32() {
766 let scale = ScalaScale::parse(
769 "Atom Schisma\n 2\n 156348578434374084375/147573952589676412928\n 2/1\n",
770 )
771 .unwrap();
772
773 let degree = scale.degrees()[1];
774 assert!(degree.as_fraction().is_none(), "not exactly representable");
775 assert!(
776 (degree.cents() - 99.993_599_6).abs() < 1e-6,
777 "{}",
778 degree.cents()
779 );
780 }
781
782 #[test]
783 fn parses_latin1_description_bytes_lossily() {
784 let bytes = b"Caf\xe9 scale\n 1\n 2/1\n";
786 let scale = ScalaScale::parse_bytes(bytes).unwrap();
787 assert!(scale.description().starts_with("Caf"));
788 assert_eq!(scale.len(), 1);
789 }
790
791 #[test]
792 fn reads_equal_tempered_step_notation() {
793 let scale = ScalaScale::parse("41-EDO\n 2\n 1\\41\n 41\\41\n").unwrap();
795 assert!((scale.degrees()[1].cents() - 1200.0 / 41.0).abs() < 1e-9);
796 assert!((scale.period().cents() - 1200.0).abs() < 1e-9);
797 }
798
799 #[test]
800 fn rejects_a_zero_division_step() {
801 assert!(ScalaScale::parse("Bad\n 1\\0\n").is_err());
802 }
803
804 #[test]
805 fn rejects_malformed_input() {
806 for bad in [
807 "", "Only a description\n", "Bad count\n not-a-number\n", "Too few\n 4\n 3/2\n 2/1\n", "Bad ratio\n 1\n 3/0\n", "Bad ratio\n 1\n 1/2/3\n", ] {
814 assert!(
815 ScalaScale::parse(bad).is_err(),
816 "expected {bad:?} to be rejected"
817 );
818 }
819 }
820
821 fn archive_of(names: &[&str]) -> ScalaArchive {
822 let mut archive = ScalaArchive::new();
823 for name in names {
824 archive
825 .insert(*name, b"A scale\n 1\n 2/1\n")
826 .expect("fixture scale parses");
827 }
828 archive
829 }
830
831 #[test]
832 fn archive_indexes_and_retrieves_by_file_name() {
833 let archive = archive_of(&["partch_43.scl", "slendro5_2.scl"]);
834 assert_eq!(archive.len(), 2);
835 assert!(archive.get("partch_43.scl").is_some());
836 assert!(archive.get("missing.scl").is_none());
837 assert_eq!(
838 archive.names().collect::<Vec<_>>(),
839 ["partch_43.scl", "slendro5_2.scl"]
840 );
841 }
842
843 #[test]
844 fn archive_search_matches_music21_semantics() {
845 let archive = archive_of(&[
846 "mbira_banda.scl",
847 "mbira_banda2.scl",
848 "mbira_zimb.scl",
849 "slendro5_2.scl",
850 "partch_43.scl",
851 ]);
852
853 assert_eq!(
855 archive.search("mbira"),
856 ["mbira_banda.scl", "mbira_banda2.scl", "mbira_zimb.scl"]
857 );
858 assert_eq!(
860 archive.search("mbira banda"),
861 ["mbira_banda.scl", "mbira_banda2.scl"]
862 );
863 assert_eq!(
865 archive.search("mbirabanda"),
866 ["mbira_banda.scl", "mbira_banda2.scl"]
867 );
868 assert_eq!(archive.search("PARTCH"), ["partch_43.scl"]);
870 assert_eq!(archive.search("slendro5_2.scl"), ["slendro5_2.scl"]);
872 assert!(archive.search("nothing-here").is_empty());
873 }
874
875 #[test]
876 fn archive_reports_the_offending_file_on_a_parse_error() {
877 let mut archive = ScalaArchive::new();
878 let error = archive
879 .insert("broken.scl", b"Broken\n not-a-number\n")
880 .expect_err("should reject");
881 assert!(error.to_string().contains("broken.scl"), "{error}");
882 }
883
884 #[test]
885 fn decodes_latin1_description_bytes() {
886 let scale = ScalaScale::parse_bytes(b"Caf\xe9\n 1\n 2/1\n").unwrap();
888 assert_eq!(scale.description(), "Caf\u{e9}");
889 }
890 #[test]
891 fn round_trips_through_from_str() {
892 let scale: ScalaScale = FIFTH_AND_OCTAVE.parse().unwrap();
893 assert_eq!(scale.len(), 2);
894 }
895}