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
+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;
}