note: close every value type's domain at construction, so resolveNote is finite for every constructible input

Division and OffsetAmount get single normalizing doors and private constructors; fromBpm validates by running the conversions rather than their reciprocal. Readers drop their re-clamps and default labels.
This commit is contained in:
2026-07-30 20:37:12 -04:00
parent d923b352ae
commit a80eb76c1f
11 changed files with 398 additions and 119 deletions
+45 -3
View File
@@ -2,9 +2,10 @@
// framework. Same fast assert loop as the sibling pure tests.
//
// Covers: BPM validation (the only rejection point, which is what makes the conversions
// total); seconds-per-beat at several tempos; beats<->seconds and beats<->ms round-trips
// across tempos and signs; the proportionality between two tempos, asserted as a ratio
// rather than against any fixed seconds value.
// total) including the tempos whose reciprocal is finite but whose conversions overflow;
// seconds-per-beat at several tempos; beats<->seconds and beats<->ms round-trips across
// tempos and signs; the proportionality between two tempos, asserted as a ratio rather than
// against any fixed seconds value.
#include "../src/core/instrument/note/tempo.h"
@@ -46,6 +47,44 @@ static void testUnusableBpmIsRejected() {
CHECK(!Tempo::fromBpm(1e-310).has_value());
}
static void testBpmWhoseReciprocalIsFineButWhoseConversionsOverflowIsRejected() {
// The gap a guard on 60/bpm alone leaves open: the reciprocal is an ordinary finite
// double, and the multiply that follows it is what blows up.
CHECK(std::isfinite(60.0 / 1e-306));
CHECK(!Tempo::fromBpm(1e-306).has_value());
// The fast end fails in the other direction — the divide, not the multiply.
CHECK(!Tempo::fromBpm(1e308).has_value());
}
static void testTheGuardAdmitsEveryRealTempoAndFarBeyond() {
// The guard is structural, not musical, so it must not have narrowed onto the range of
// tempos anyone would type. The extremes here are orders of magnitude past that.
for (double bpm : {1e-200, 1e-6, 0.001, 1.0, 20.0, 120.0, 240.0, 960.0, 1e6, 1e100}) {
CHECK(Tempo::fromBpm(bpm).has_value());
}
}
static void testEveryAcceptedTempoConvertsTheWholeDomainFinitely() {
// What the guard is FOR: past it, no conversion of a magnitude the module admits can
// reach inf or NaN, in either unit or either direction.
int accepted = 0, rejected = 0;
for (double bpm : {1e-320, 1e-306, 1e-300, 1e-100, 1e-6, 0.5, 120.0, 1e6, 1e100, 1e250,
1e308}) {
const std::optional<Tempo> t = Tempo::fromBpm(bpm);
if (!t) { ++rejected; continue; }
++accepted;
for (double m : {-kMaxConvertibleMagnitude, -1.0, 0.0, 1.0, kMaxConvertibleMagnitude}) {
CHECK(std::isfinite(t->beatsToSeconds(m)));
CHECK(std::isfinite(t->beatsToMs(m)));
CHECK(std::isfinite(t->msToBeats(m)));
CHECK(std::isfinite(t->secondsToBeats(msToSeconds(m))));
}
}
// Neither half of the sweep may be empty, or the loop above proves nothing.
CHECK(accepted > 0);
CHECK(rejected > 0);
}
// --- Conversions ---------------------------------------------------------------
static void testSecondsPerBeatFollowsBpm() {
@@ -121,6 +160,9 @@ static void testMillisecondsAreTempoFree() {
int main() {
testUsableBpmIsAccepted();
testUnusableBpmIsRejected();
testBpmWhoseReciprocalIsFineButWhoseConversionsOverflowIsRejected();
testTheGuardAdmitsEveryRealTempoAndFarBeyond();
testEveryAcceptedTempoConvertsTheWholeDomainFinitely();
testSecondsPerBeatFollowsBpm();
testBeatsToSecondsAtAKnownTempo();