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:
2026-08-01 21:46:53 -04:00
parent 09d64c9f46
commit 3278b4eced
23 changed files with 626 additions and 25 deletions
+31
View File
@@ -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();
+252
View File
@@ -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;
}
+44
View File
@@ -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();