fix: harden note-program model against corrupt tempo/division/denomination records

Reject subnormal BPM that overflows to NaN, normalize Division equality, pin a single out-of-range-denomination interpretation across all readers, flag collapsed capture windows, add offset off-view editors and structural static_asserts, collapse duplicated CLAUDE.md facts.
This commit is contained in:
2026-07-30 20:11:06 -04:00
parent 834a6ddcc7
commit d923b352ae
10 changed files with 175 additions and 36 deletions
+19 -5
View File
@@ -111,16 +111,19 @@ static void testNamedExamples() {
// --- Picker order -------------------------------------------------------------
static void testPickerOrderIsShortestFirst() {
// Straight lengths ascend across rungs; within a rung the order is straight, dotted,
// triplet (so the index is not itself sorted by duration — only the rungs are).
// Straight lengths ascend across rungs; within EVERY rung (not just rung 0) the order is
// straight, dotted, triplet so the index is not itself sorted by duration.
for (int rung = 1; rung < kRungCount; ++rung) {
const double prev = divisionBeats(divisionAt((rung - 1) * kModifierCount));
const double here = divisionBeats(divisionAt(rung * kModifierCount));
CHECK(here > prev);
}
CHECK(divisionAt(0) == makeDivision(kMinQuarterExponent, DivisionModifier::Straight));
CHECK(divisionAt(1) == makeDivision(kMinQuarterExponent, DivisionModifier::Dotted));
CHECK(divisionAt(2) == makeDivision(kMinQuarterExponent, DivisionModifier::Triplet));
for (int rung = 0; rung < kRungCount; ++rung) {
const int e = kMinQuarterExponent + rung;
CHECK(divisionAt(rung * kModifierCount + 0) == makeDivision(e, DivisionModifier::Straight));
CHECK(divisionAt(rung * kModifierCount + 1) == makeDivision(e, DivisionModifier::Dotted));
CHECK(divisionAt(rung * kModifierCount + 2) == makeDivision(e, DivisionModifier::Triplet));
}
}
static void testIndexRoundTripsOverTheWholeSet() {
@@ -151,6 +154,16 @@ static void testOffLadderExponentClampsToTheNearestRung() {
CHECK(almostEqual(divisionBeats(corrupt), 256.0));
}
static void testEqualityNormalizesOffLadderExponentsLikeEveryOtherReader() {
// divisionBeats/divisionIndex/divisionLabel all re-clamp through makeDivision; equality
// must too, or a corrupt persisted value reads as a spurious diff on every reload.
Division corrupt;
corrupt.quarterExponent = 120;
corrupt.modifier = DivisionModifier::Straight;
CHECK(corrupt == makeDivision(kMaxQuarterExponent, DivisionModifier::Straight));
CHECK(corrupt != makeDivision(kMaxQuarterExponent, DivisionModifier::Dotted));
}
static void testOutOfRangeIndexClampsIntoTheSet() {
CHECK(divisionAt(-1) == divisionAt(0));
CHECK(divisionAt(kDivisionCount) == divisionAt(kDivisionCount - 1));
@@ -169,6 +182,7 @@ int main() {
testEverySetMemberIsDistinct();
testOffLadderExponentClampsToTheNearestRung();
testEqualityNormalizesOffLadderExponentsLikeEveryOtherReader();
testOutOfRangeIndexClampsIntoTheSet();
if (g_fail == 0) std::printf("musical_division: all tests passed\n");
+73 -5
View File
@@ -1,10 +1,11 @@
// Standalone tests for reasampler::instrument::note::note_program — no VST3, no REAPER, no
// framework. Same fast assert loop as the sibling pure tests.
//
// Covers: velocity clamping; the ms/beats denomination seam and its round-trip; anchoring
// (start to note-on, end to note-off); the resolved window against hand-computed values;
// every division resolving to its duration in seconds; proportionality across two tempos;
// record equality and copy round-trip.
// Covers: velocity clamping; the ms/beats denomination seam and its round-trip, including a
// corrupt denomination byte; anchoring (start to note-on, end to note-off); the resolved
// window against hand-computed values and its windowCollapsed flag; every division resolving
// to its duration in seconds; proportionality across two tempos; record equality and copy
// round-trip; editing an offset via its non-stored view (withMsView/withBeatsView).
#include "../src/core/instrument/note/note_program.h"
@@ -323,7 +324,67 @@ static void testDefaultRecordIsAQuarterNoteWithNoOffsets() {
CHECK(almostEqual(r.noteOffSeconds, 0.5));
CHECK(almostEqual(r.captureStartSeconds, 0.0));
CHECK(almostEqual(r.captureEndSeconds, 0.5));
CHECK(r.velocity >= Velocity::kMin && r.velocity <= Velocity::kMax);
CHECK(r.velocity == 100); // NoteProgram{}'s default Velocity, documented in note_program.h
}
// --- Corrupt denomination byte --------------------------------------------------
static void testOutOfRangeDenominationReadsAsMillisecondsEverywhere() {
// A denomination byte outside {Milliseconds, Beats} is well-defined but unnamed; all
// three readers must default it to the same interpretation or a popup and a bake can
// report different instants for one record.
OffsetAmount corrupt;
corrupt.magnitude = 250.0;
corrupt.denomination = static_cast<Denomination>(7);
const Tempo t = at(120.0);
CHECK(almostEqual(offsetMs(corrupt, t), 250.0));
CHECK(almostEqual(offsetSeconds(corrupt, t), 0.25));
CHECK(almostEqual(offsetBeats(corrupt, t), t.msToBeats(250.0)));
}
// --- windowCollapsed -------------------------------------------------------------
static void testWindowCollapsedFlagsAnInvertedWindow() {
const Tempo t = at(120.0);
const ResolvedNote inverted = resolveNote(
program(makeDivision(0, DivisionModifier::Straight), offsetFromMs(0.0),
offsetFromMs(-5000.0), 100),
t);
CHECK(inverted.windowCollapsed);
const ResolvedNote normal = resolveNote(
program(makeDivision(0, DivisionModifier::Straight), offsetFromMs(-20.0),
offsetFromMs(500.0), 100),
t);
CHECK(!normal.windowCollapsed);
}
// --- Editing via the non-stored view ---------------------------------------------
static void testWithMsViewPreservesTheStoredDenomination() {
const Tempo t = at(120.0); // one beat is 500 ms
const OffsetAmount msOffset = offsetFromMs(10.0);
const OffsetAmount editedMs = withMsView(msOffset, 40.0, t);
CHECK(editedMs.denomination == Denomination::Milliseconds);
CHECK(almostEqual(editedMs.magnitude, 40.0));
const OffsetAmount beatsOffset = offsetFromBeats(1.0);
const OffsetAmount editedBeats = withMsView(beatsOffset, 250.0, t);
CHECK(editedBeats.denomination == Denomination::Beats); // stays beats-denominated
CHECK(almostEqual(offsetMs(editedBeats, t), 250.0, 1e-6)); // but reads back as 250 ms
}
static void testWithBeatsViewPreservesTheStoredDenomination() {
const Tempo t = at(120.0); // one beat is 500 ms
const OffsetAmount beatsOffset = offsetFromBeats(0.5);
const OffsetAmount editedBeats = withBeatsView(beatsOffset, 2.0, t);
CHECK(editedBeats.denomination == Denomination::Beats);
CHECK(almostEqual(editedBeats.magnitude, 2.0));
const OffsetAmount msOffset = offsetFromMs(100.0);
const OffsetAmount editedMs = withBeatsView(msOffset, 1.0, t);
CHECK(editedMs.denomination == Denomination::Milliseconds); // stays ms-denominated
CHECK(almostEqual(offsetBeats(editedMs, t), 1.0)); // but reads back as 1 beat
}
int main() {
@@ -353,6 +414,13 @@ int main() {
testRedenominatedRecordDescribesTheSameWindow();
testDefaultRecordIsAQuarterNoteWithNoOffsets();
testOutOfRangeDenominationReadsAsMillisecondsEverywhere();
testWindowCollapsedFlagsAnInvertedWindow();
testWithMsViewPreservesTheStoredDenomination();
testWithBeatsViewPreservesTheStoredDenomination();
if (g_fail == 0) std::printf("note_program: all tests passed\n");
else std::printf("note_program: %d FAILED\n", g_fail);
return g_fail == 0 ? 0 : 1;
+3
View File
@@ -41,6 +41,9 @@ static void testUnusableBpmIsRejected() {
CHECK(!Tempo::fromBpm(-120.0).has_value());
CHECK(!Tempo::fromBpm(std::numeric_limits<double>::quiet_NaN()).has_value());
CHECK(!Tempo::fromBpm(std::numeric_limits<double>::infinity()).has_value());
// Finite, positive, subnormal — but 60/bpm overflows to +inf, which turns
// beatsToSeconds(0) into NaN downstream if let through.
CHECK(!Tempo::fromBpm(1e-310).has_value());
}
// --- Conversions ---------------------------------------------------------------