diff --git a/Semantics.Music/Tempo.cs b/Semantics.Music/Tempo.cs
index 706db17..fffaab7 100644
--- a/Semantics.Music/Tempo.cs
+++ b/Semantics.Music/Tempo.cs
@@ -21,25 +21,30 @@ public sealed record Tempo
public double SecondsPerBeat => 60.0 / BeatsPerMinute;
/// Creates a tempo with a quarter-note beat.
- /// The tempo in beats per minute; must be positive.
+ /// The tempo in beats per minute; must be positive and finite.
/// A new tempo.
- /// Thrown when is not positive.
+ /// Thrown when is not positive and finite.
public static Tempo Create(double beatsPerMinute) => Create(beatsPerMinute, Duration.Quarter);
/// Creates a tempo with an explicit beat unit.
- /// The tempo in beats per minute; must be positive.
- /// The note duration that counts as one beat.
+ /// The tempo in beats per minute; must be positive and finite.
+ /// The note duration that counts as one beat; must be positive.
/// A new tempo.
- /// Thrown when is not positive.
+ /// Thrown when is not positive and finite, or is not positive.
/// Thrown when is null.
public static Tempo Create(double beatsPerMinute, Duration beat)
{
- if (beatsPerMinute <= 0.0 || double.IsNaN(beatsPerMinute))
+ if (!IsValidBeatsPerMinute(beatsPerMinute))
{
- throw new ArgumentOutOfRangeException(nameof(beatsPerMinute), beatsPerMinute, "Tempo must be positive.");
+ throw new ArgumentOutOfRangeException(nameof(beatsPerMinute), beatsPerMinute, "Tempo must be positive and finite.");
}
Ensure.NotNull(beat);
+ if (!IsValidBeat(beat))
+ {
+ throw new ArgumentOutOfRangeException(nameof(beat), beat, "Beat must be a positive duration.");
+ }
+
return new() { BeatsPerMinute = beatsPerMinute, Beat = beat };
}
@@ -85,8 +90,9 @@ public static bool TryParse(string? text, [System.Diagnostics.CodeAnalysis.NotNu
}
if (!double.TryParse(text[..marker], NumberStyles.Float, CultureInfo.InvariantCulture, out double bpm)
- || bpm <= 0.0
- || !Duration.TryParse(text[(marker + 4)..], out Duration? beat))
+ || !IsValidBeatsPerMinute(bpm)
+ || !Duration.TryParse(text[(marker + 4)..], out Duration? beat)
+ || !IsValidBeat(beat))
{
return false;
}
@@ -95,6 +101,13 @@ public static bool TryParse(string? text, [System.Diagnostics.CodeAnalysis.NotNu
return true;
}
+ // NaN fails the comparison, so only infinity needs its own check.
+ private static bool IsValidBeatsPerMinute(double beatsPerMinute) =>
+ beatsPerMinute > 0.0 && !double.IsInfinity(beatsPerMinute);
+
+ // Seconds divides by the beat, so a zero beat gives Infinity and a negative one negative time.
+ private static bool IsValidBeat(Duration beat) => beat.Numerator > 0;
+
/// Returns "{bpm}bpm@{beat}" (e.g. "120bpm@1/4").
/// The canonical tempo text.
public override string ToString() =>
diff --git a/Semantics.Test/Music/TempoTests.cs b/Semantics.Test/Music/TempoTests.cs
index fbe88ba..3f6fade 100644
--- a/Semantics.Test/Music/TempoTests.cs
+++ b/Semantics.Test/Music/TempoTests.cs
@@ -2,6 +2,8 @@
namespace ktsu.Semantics.Test.Music;
+using System;
+
using ktsu.Semantics.Music;
[TestClass]
@@ -27,4 +29,45 @@ public void TryParseFailsWhenMarkerMissing()
Assert.IsFalse(Tempo.TryParse("120", out Tempo? result));
Assert.IsNull(result);
}
+
+ [TestMethod]
+ [DataRow("NaNbpm@1/4")]
+ [DataRow("-NaNbpm@1/4")]
+ [DataRow("Infinitybpm@1/4")]
+ [DataRow("-Infinitybpm@1/4")]
+ [DataRow("0bpm@1/4")]
+ [DataRow("-120bpm@1/4")]
+ [DataRow("120bpm@0/4")]
+ [DataRow("120bpm@-1/4")]
+ [DataRow("120bpm@1/-4")]
+ public void TryParseReturnsFalseForInvalidTempo(string text)
+ {
+ Assert.IsFalse(Tempo.TryParse(text, out Tempo? result));
+ Assert.IsNull(result);
+ }
+
+ [TestMethod]
+ public void TryParseAcceptsValidTempo()
+ {
+ Assert.IsTrue(Tempo.TryParse("120bpm@1/4", out Tempo? result));
+ Assert.AreEqual(Tempo.Create(120), result);
+ Assert.AreEqual("120bpm@1/4", result.ToString());
+ }
+
+ [TestMethod]
+ [DataRow(double.NaN)]
+ [DataRow(double.PositiveInfinity)]
+ [DataRow(double.NegativeInfinity)]
+ [DataRow(0.0)]
+ [DataRow(-1.0)]
+ public void CreateThrowsForInvalidBeatsPerMinute(double beatsPerMinute) =>
+ Assert.ThrowsExactly(() => Tempo.Create(beatsPerMinute, Duration.Quarter));
+
+ [TestMethod]
+ [DataRow(0, 1)]
+ [DataRow(0, 4)]
+ [DataRow(-1, 4)]
+ [DataRow(1, -4)]
+ public void CreateThrowsForNonPositiveBeat(int numerator, int denominator) =>
+ Assert.ThrowsExactly(() => Tempo.Create(120, Duration.Create(numerator, denominator)));
}