Name captures after their source track: label and filename both, on every interactive mint site, and show the name on the panel card
This commit is contained in:
@@ -489,6 +489,36 @@ static void testLegacyJsonDefaults() {
|
||||
}
|
||||
}
|
||||
|
||||
// A bank written before captures were named after their source track carries the literal
|
||||
// "item"/"track" label. Nothing migrates or relabels it: the label must survive load and
|
||||
// re-serialize byte-identically, so an old bank reads exactly as it did.
|
||||
static void testPreNamingSchemeLabelsSurviveUntouched() {
|
||||
const char* old =
|
||||
"{\"version\":1,\"samples\":["
|
||||
"{\"id\":\"cap-1700000000-1-item_1700000000-1.wav\","
|
||||
"\"displayName\":\"item\",\"relativePath\":\"reasampler_bank/item_1700000000-1.wav\","
|
||||
"\"contentHash\":\"h-old-a\"},"
|
||||
"{\"id\":\"cap-1700000000-2-track_1700000000-2.wav\","
|
||||
"\"displayName\":\"track\",\"relativePath\":\"reasampler_bank/track_1700000000-2.wav\","
|
||||
"\"contentHash\":\"h-old-b\"}]}";
|
||||
auto r = BankModel::deserialize(old);
|
||||
CHECK(r.has_value());
|
||||
if (!r) return;
|
||||
|
||||
const Sample* a = r->query("cap-1700000000-1-item_1700000000-1.wav");
|
||||
const Sample* b = r->query("cap-1700000000-2-track_1700000000-2.wav");
|
||||
CHECK(a && a->displayName == "item");
|
||||
CHECK(b && b->displayName == "track");
|
||||
|
||||
auto again = BankModel::deserialize(r->serialize());
|
||||
CHECK(again.has_value());
|
||||
CHECK(again && *again == *r);
|
||||
if (again) {
|
||||
const Sample* a2 = again->query("cap-1700000000-1-item_1700000000-1.wav");
|
||||
CHECK(a2 && a2->displayName == "item");
|
||||
}
|
||||
}
|
||||
|
||||
// S2 test case 4: boundary values for the seam fields are representable and
|
||||
// round-trip. rootNote 0 and 127 (the MIDI edges); loopStart == loopEnd (a valid
|
||||
// zero-length marker); a loop whose end sits at the file's last frame. Also asserts
|
||||
@@ -588,6 +618,7 @@ int main() {
|
||||
testIntegerOverflow();
|
||||
testEnumRangeValidation();
|
||||
testLegacyJsonDefaults();
|
||||
testPreNamingSchemeLabelsSurviveUntouched();
|
||||
testSeamFieldBoundaries();
|
||||
testSeamFieldsAdditiveInvariant();
|
||||
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
// Standalone tests for reasampler::capture_name — no REAPER, no framework. Covers the
|
||||
// name SHAPE (label vs file-stem base, multi-source marker, batch ordinal, discriminator)
|
||||
// and the awkward source names: empty, all-punctuation, non-ASCII, over-long, duplicate.
|
||||
//
|
||||
// The stem base is asserted through sanitizeStem here as well as raw, because the stem's
|
||||
// real contract is "survives the sanitizer as something filesystem-legal", not "equals
|
||||
// this string" — sanitizeStem is the function that has to hold, and it is capture_paths'.
|
||||
|
||||
#include "../src/core/capture/capture_name.h"
|
||||
#include "../src/core/capture/capture_paths.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
using namespace reasampler;
|
||||
using namespace reasampler::capture;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
// A fixed stamp so every expectation below is a literal, not a re-derivation.
|
||||
static CaptureStamp stamp() { return CaptureStamp{8, 1, 14, 32}; }
|
||||
|
||||
static CaptureNameInputs inputsFor(std::vector<std::string> names,
|
||||
int ordinal = 0,
|
||||
const std::string& fallback = "item") {
|
||||
CaptureNameInputs in;
|
||||
in.sourceNames = std::move(names);
|
||||
in.stamp = stamp();
|
||||
in.ordinal = ordinal;
|
||||
in.fallback = fallback;
|
||||
return in;
|
||||
}
|
||||
|
||||
// --- the discriminator -------------------------------------------------------
|
||||
|
||||
static void testStampIsZeroPaddedMonthDayHourMinute() {
|
||||
CHECK(formatCaptureStamp(CaptureStamp{8, 1, 14, 32}) == "08-01 1432");
|
||||
CHECK(formatCaptureStamp(CaptureStamp{12, 25, 0, 5}) == "12-25 0005");
|
||||
}
|
||||
|
||||
static void testUnsetStampProducesNoDiscriminator() {
|
||||
// A failed clock read leaves the stamp zeroed; the label must degrade to the bare
|
||||
// name rather than render "00-00 0000".
|
||||
CHECK(formatCaptureStamp(CaptureStamp{}) == "");
|
||||
const CaptureNameInputs in{{"Bass"}, CaptureStamp{}, 0, "item"};
|
||||
CHECK(composeCaptureName(in).label == "Bass");
|
||||
}
|
||||
|
||||
// --- ordinary derivation ------------------------------------------------------
|
||||
|
||||
static void testOrdinaryNameLabelsAndFilesAfterTheTrack() {
|
||||
const CaptureName n = composeCaptureName(inputsFor({"Bass"}));
|
||||
CHECK(n.label == "Bass 08-01 1432");
|
||||
CHECK(n.stemBase == "Bass");
|
||||
CHECK(sanitizeStem(n.stemBase) == "Bass");
|
||||
}
|
||||
|
||||
static void testTwoCapturesMinutesApartAreDistinguishable() {
|
||||
CaptureNameInputs a = inputsFor({"Bass"});
|
||||
CaptureNameInputs b = inputsFor({"Bass"});
|
||||
b.stamp.minute = 47;
|
||||
CHECK(composeCaptureName(a).label != composeCaptureName(b).label);
|
||||
CHECK(composeCaptureName(a).label == "Bass 08-01 1432");
|
||||
CHECK(composeCaptureName(b).label == "Bass 08-01 1447");
|
||||
}
|
||||
|
||||
static void testNameWithSpacesKeepsThemInTheLabelAndSanitizesInTheStem() {
|
||||
const CaptureName n = composeCaptureName(inputsFor({"Lead Vox"}));
|
||||
CHECK(n.label == "Lead Vox 08-01 1432");
|
||||
CHECK(sanitizeStem(n.stemBase) == "Lead_Vox");
|
||||
}
|
||||
|
||||
static void testSurroundingWhitespaceIsTrimmed() {
|
||||
// Untrimmed, this would file as "__Bass__" and read ragged on the card.
|
||||
const CaptureName n = composeCaptureName(inputsFor({" Bass "}));
|
||||
CHECK(n.label == "Bass 08-01 1432");
|
||||
CHECK(sanitizeStem(n.stemBase) == "Bass");
|
||||
}
|
||||
|
||||
// --- awkward names ------------------------------------------------------------
|
||||
|
||||
static void testEmptyNameFallsBackToTheScopeLiteral() {
|
||||
// Unreachable in the DAW (GetTrackName answers "Track N" for an unnamed track), so
|
||||
// this pins the defensive path: the scope literal, never an empty label.
|
||||
const CaptureName n = composeCaptureName(inputsFor({""}, 0, "item"));
|
||||
CHECK(n.label == "item 08-01 1432");
|
||||
CHECK(sanitizeStem(n.stemBase) == "item");
|
||||
}
|
||||
|
||||
static void testNoSourceAtAllFallsBackToTheScopeLiteral() {
|
||||
const CaptureName n = composeCaptureName(inputsFor({}, 0, "track"));
|
||||
CHECK(n.label == "track 08-01 1432");
|
||||
CHECK(sanitizeStem(n.stemBase) == "track");
|
||||
}
|
||||
|
||||
static void testEmptyNameAndEmptyFallbackStillYieldALegalStem() {
|
||||
const CaptureName n = composeCaptureName(inputsFor({""}, 0, ""));
|
||||
CHECK(n.label == "capture 08-01 1432");
|
||||
CHECK(sanitizeStem(n.stemBase) == "capture");
|
||||
}
|
||||
|
||||
static void testUnnamedTrackUsesReaperTrackNConvention() {
|
||||
// What GetTrackName actually hands back for an unnamed track — the deterministic
|
||||
// fallback rides in as an ordinary name, no special case in the composer.
|
||||
const CaptureName n = composeCaptureName(inputsFor({"Track 3"}));
|
||||
CHECK(n.label == "Track 3 08-01 1432");
|
||||
CHECK(sanitizeStem(n.stemBase) == "Track_3");
|
||||
}
|
||||
|
||||
static void testAllPunctuationNameKeepsTheLabelAndCollapsesTheStem() {
|
||||
const CaptureName n = composeCaptureName(inputsFor({"***"}));
|
||||
CHECK(n.label == "*** 08-01 1432"); // the label is display-only; punctuation is fine
|
||||
CHECK(n.stemBase == "***");
|
||||
CHECK(sanitizeStem(n.stemBase) == "capture"); // nothing alnum survives
|
||||
}
|
||||
|
||||
static void testNonAsciiNameKeepsTheLabelAndCollapsesTheStem() {
|
||||
const std::string kana = "\xE3\x83\x99\xE3\x83\xBC\xE3\x82\xB9"; // UTF-8 "ベース"
|
||||
const CaptureName n = composeCaptureName(inputsFor({kana}));
|
||||
CHECK(n.label == kana + " 08-01 1432");
|
||||
CHECK(sanitizeStem(n.stemBase) == "capture");
|
||||
}
|
||||
|
||||
static void testMixedAsciiAndNonAsciiKeepsTheAsciiPartInTheStem() {
|
||||
const std::string mixed = "Bass\xC3\xA9"; // "Bassé"
|
||||
const CaptureName n = composeCaptureName(inputsFor({mixed}));
|
||||
const std::string stem = sanitizeStem(n.stemBase);
|
||||
CHECK(stem.rfind("Bass", 0) == 0); // recognizable
|
||||
CHECK(stem != "capture"); // did not collapse
|
||||
}
|
||||
|
||||
static void testOverLongNameIsBoundedInBothLabelAndStem() {
|
||||
const std::string huge(400, 'x');
|
||||
const CaptureName n = composeCaptureName(inputsFor({huge}));
|
||||
CHECK(n.stemBase.size() == kMaxSourceNameBytes);
|
||||
CHECK(sanitizeStem(n.stemBase).size() == kMaxSourceNameBytes);
|
||||
// Label = bounded name + " MM-DD HHMM".
|
||||
CHECK(n.label.size() == kMaxSourceNameBytes + 11);
|
||||
}
|
||||
|
||||
static void testOverLongNonAsciiNameIsNotCutMidCharacter() {
|
||||
// 3-byte characters do not tile the 64-byte bound evenly, so a naive cut would leave
|
||||
// a truncated sequence in a label that goes on to be persisted as JSON.
|
||||
std::string kana;
|
||||
for (int i = 0; i < 60; ++i) kana += "\xE3\x83\x99"; // 180 bytes of "ベ"
|
||||
const CaptureName n = composeCaptureName(inputsFor({kana}));
|
||||
CHECK(n.stemBase.size() % 3 == 0);
|
||||
CHECK(n.stemBase.size() <= kMaxSourceNameBytes);
|
||||
CHECK(n.stemBase.size() > kMaxSourceNameBytes - 3); // took as much as fits
|
||||
}
|
||||
|
||||
static void testTwoTracksWithTheSameNameComposeIdentically() {
|
||||
// Deliberate: displayName is explicitly NOT unique, and stem uniqueness is
|
||||
// makeUniqueTag's job, not the composer's.
|
||||
const CaptureName a = composeCaptureName(inputsFor({"Bass"}));
|
||||
const CaptureName b = composeCaptureName(inputsFor({"Bass"}));
|
||||
CHECK(a.label == b.label);
|
||||
CHECK(a.stemBase == b.stemBase);
|
||||
}
|
||||
|
||||
// --- multi-source -------------------------------------------------------------
|
||||
|
||||
static void testMultiTrackSourceMarksTheExtraCount() {
|
||||
const CaptureName n = composeCaptureName(inputsFor({"Bass", "Drums", "Keys"}));
|
||||
CHECK(n.label == "Bass +2 08-01 1432");
|
||||
CHECK(n.stemBase == "Bass+2");
|
||||
CHECK(sanitizeStem(n.stemBase) == "Bass_2");
|
||||
}
|
||||
|
||||
static void testMultiTrackSourceIgnoresUnnamedEntriesInTheCount() {
|
||||
const CaptureName n = composeCaptureName(inputsFor({"Bass", ""}));
|
||||
CHECK(n.label == "Bass 08-01 1432"); // one real source, no marker
|
||||
}
|
||||
|
||||
static void testMultiTrackSourceNamesAfterTheFirstNamedTrack() {
|
||||
const CaptureName n = composeCaptureName(inputsFor({"", "Drums", "Keys"}));
|
||||
CHECK(n.label == "Drums +1 08-01 1432");
|
||||
}
|
||||
|
||||
// --- batch ordinals -----------------------------------------------------------
|
||||
|
||||
static void testBatchOrdinalDistinguishesUnitsFromOneTrack() {
|
||||
const CaptureName a = composeCaptureName(inputsFor({"Bass"}, 1));
|
||||
const CaptureName b = composeCaptureName(inputsFor({"Bass"}, 2));
|
||||
CHECK(a.label == "Bass #1 08-01 1432");
|
||||
CHECK(b.label == "Bass #2 08-01 1432");
|
||||
CHECK(sanitizeStem(a.stemBase) == "Bass-1");
|
||||
CHECK(sanitizeStem(b.stemBase) == "Bass-2");
|
||||
}
|
||||
|
||||
static void testOrdinalZeroAddsNothing() {
|
||||
CHECK(composeCaptureName(inputsFor({"Bass"}, 0)).stemBase == "Bass");
|
||||
}
|
||||
|
||||
static void testOrdinalAndMultiSourceCompose() {
|
||||
const CaptureName n = composeCaptureName(inputsFor({"Bass", "Drums"}, 3));
|
||||
CHECK(n.label == "Bass +1 #3 08-01 1432");
|
||||
CHECK(sanitizeStem(n.stemBase) == "Bass_1-3");
|
||||
}
|
||||
|
||||
// --- stem legality across every awkward input ---------------------------------
|
||||
|
||||
static void testEveryAwkwardStemStaysFilesystemLegal() {
|
||||
const std::string kana = "\xE3\x83\x99\xE3\x83\xBC\xE3\x82\xB9";
|
||||
const std::vector<std::string> names = {
|
||||
"Bass", "", "***", kana, std::string(400, 'x'), "Lead Vox", "Track 3",
|
||||
"a/b\\c:d*e?f\"g<h>i|j",
|
||||
};
|
||||
for (const std::string& raw : names) {
|
||||
const std::string stem = sanitizeStem(composeCaptureName(inputsFor({raw})).stemBase);
|
||||
CHECK(!stem.empty());
|
||||
for (unsigned char c : stem) {
|
||||
const bool legal = (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
|
||||
(c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-';
|
||||
CHECK(legal);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
testStampIsZeroPaddedMonthDayHourMinute();
|
||||
testUnsetStampProducesNoDiscriminator();
|
||||
testOrdinaryNameLabelsAndFilesAfterTheTrack();
|
||||
testTwoCapturesMinutesApartAreDistinguishable();
|
||||
testNameWithSpacesKeepsThemInTheLabelAndSanitizesInTheStem();
|
||||
testSurroundingWhitespaceIsTrimmed();
|
||||
testEmptyNameFallsBackToTheScopeLiteral();
|
||||
testNoSourceAtAllFallsBackToTheScopeLiteral();
|
||||
testEmptyNameAndEmptyFallbackStillYieldALegalStem();
|
||||
testUnnamedTrackUsesReaperTrackNConvention();
|
||||
testAllPunctuationNameKeepsTheLabelAndCollapsesTheStem();
|
||||
testNonAsciiNameKeepsTheLabelAndCollapsesTheStem();
|
||||
testMixedAsciiAndNonAsciiKeepsTheAsciiPartInTheStem();
|
||||
testOverLongNameIsBoundedInBothLabelAndStem();
|
||||
testOverLongNonAsciiNameIsNotCutMidCharacter();
|
||||
testTwoTracksWithTheSameNameComposeIdentically();
|
||||
testMultiTrackSourceMarksTheExtraCount();
|
||||
testMultiTrackSourceIgnoresUnnamedEntriesInTheCount();
|
||||
testMultiTrackSourceNamesAfterTheFirstNamedTrack();
|
||||
testBatchOrdinalDistinguishesUnitsFromOneTrack();
|
||||
testOrdinalZeroAddsNothing();
|
||||
testOrdinalAndMultiSourceCompose();
|
||||
testEveryAwkwardStemStaysFilesystemLegal();
|
||||
|
||||
if (g_fail == 0) std::printf("capture_name: all tests passed\n");
|
||||
else std::printf("capture_name: %d CHECK(s) FAILED\n", g_fail);
|
||||
return g_fail ? 1 : 0;
|
||||
}
|
||||
@@ -109,7 +109,51 @@ static void testSecondsMsNearWholeSecondCarry() {
|
||||
CHECK(r == "0.999" || r == "1.000");
|
||||
}
|
||||
|
||||
// --- the card's name strip ----------------------------------------------------
|
||||
|
||||
// The shipping cell size (panel_state.h's kGrid).
|
||||
static const Rect kCell{40, 100, 140, 84};
|
||||
|
||||
static void testNameStripSitsAcrossTheTopOfTheCell() {
|
||||
const Rect s = cardNameStrip(kCell);
|
||||
CHECK(s.x == kCell.x + kCardStripPad);
|
||||
CHECK(s.y == kCell.y + 1); // clear of the selection border
|
||||
CHECK(s.width == kCell.width - 2 * kCardStripPad);
|
||||
CHECK(s.height == kCardStripHeight);
|
||||
}
|
||||
|
||||
static void testNameStripNeverOverlapsTheLengthReadOut() {
|
||||
// The read-out occupies the bottom kCardStripHeight of the same cell.
|
||||
const Rect s = cardNameStrip(kCell);
|
||||
CHECK(s.bottom() <= kCell.bottom() - kCardStripHeight);
|
||||
}
|
||||
|
||||
static void testNameStripStaysInsideTheCell() {
|
||||
const Rect s = cardNameStrip(kCell);
|
||||
CHECK(s.x >= kCell.x);
|
||||
CHECK(s.right() <= kCell.right());
|
||||
CHECK(s.y >= kCell.y);
|
||||
CHECK(s.bottom() <= kCell.bottom());
|
||||
}
|
||||
|
||||
static void testNameStripSuppressedOnATooShortCell() {
|
||||
// Below three strip-heights the card would be text with a sliver of waveform — draw
|
||||
// no name rather than bury the thumbnail.
|
||||
CHECK(cardNameStrip(Rect{0, 0, 140, 3 * kCardStripHeight - 1}).empty());
|
||||
CHECK(!cardNameStrip(Rect{0, 0, 140, 3 * kCardStripHeight}).empty());
|
||||
}
|
||||
|
||||
static void testNameStripSuppressedOnATooNarrowCell() {
|
||||
CHECK(cardNameStrip(Rect{0, 0, 2 * kCardStripPad, 84}).empty());
|
||||
CHECK(cardNameStrip(Rect{0, 0, 0, 84}).empty());
|
||||
}
|
||||
|
||||
int main() {
|
||||
testNameStripSitsAcrossTheTopOfTheCell();
|
||||
testNameStripNeverOverlapsTheLengthReadOut();
|
||||
testNameStripStaysInsideTheCell();
|
||||
testNameStripSuppressedOnATooShortCell();
|
||||
testNameStripSuppressedOnATooNarrowCell();
|
||||
testZeroLengthIsBarOneOrigin();
|
||||
testSubBeat();
|
||||
testWholeBeatWithinBar();
|
||||
|
||||
Reference in New Issue
Block a user