// 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 #include #include #include 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 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 testAllWhitespaceNameFallsBackToTheScopeLiteral() { // Distinct path from an empty name: trimmed() is what empties it, before truncation // ever runs. const CaptureName n = composeCaptureName(inputsFor({" "}, 0, "item")); CHECK(n.label == "item 08-01 1432"); CHECK(sanitizeStem(n.stemBase) == "item"); } static void testNameThatTruncatesToNothingFallsBackToCapture() { // Over-length and made entirely of a UTF-8 continuation byte (0x80-0xBF): not empty // pre-truncation, so it survives trimmed()/the fallback chain as a real name — but // truncateUtf8 backs off over continuation bytes and walks all the way to 0 (every byte // in the first kMaxSourceNameBytes is a continuation byte), which used to leave an // empty label. const std::string allContinuation(kMaxSourceNameBytes + 10, '\x80'); const CaptureName n = composeCaptureName(inputsFor({allContinuation})); 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 names = { "Bass", "", "***", kana, std::string(400, 'x'), "Lead Vox", "Track 3", "a/b\\c:d*e?f\"gi|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); } } } // --- captureTrackName ------------------------------------------------------- static void testCaptureTrackNamePrefixesAPlainSourceName() { CHECK(captureTrackName("MONEY") == "Capture MONEY"); CHECK(captureTrackName("bass di") == "Capture bass di"); } static void testCaptureTrackNameIsIdempotent() { // The whole point: a second render over a result track must not stack the prefix. CHECK(captureTrackName("Capture MONEY") == "Capture MONEY"); CHECK(captureTrackName(captureTrackName("MONEY")) == "Capture MONEY"); // A fixed point on its own output for EVERY input, degenerate ones included. for (const char* src : {"MONEY", "", "Capture", "Capture ", "Captured drums"}) { const std::string once = captureTrackName(src); CHECK(captureTrackName(once) == once); } } static void testCaptureTrackNameEmptySourceHasNoTrailingSpace() { // Unreachable from trackName (GetTrackName always answers "Track N"), so this is // the defensive case — a bare word rather than a name ending in a space. CHECK(captureTrackName("") == "Capture"); } static void testCaptureTrackNameUnnamedSourceReadsAsCaptureTrackN() { // trackName's GetTrackName fallback rides in as an ordinary name. CHECK(captureTrackName("Track 7") == "Capture Track 7"); } static void testCaptureTrackNameDoesNotMatchAMerePrefixOfTheWord() { // "Captured" begins with "Capture" but not with "Capture " — it is a different // name and must be prefixed like any other. CHECK(captureTrackName("Captured drums") == "Capture Captured drums"); } int main() { testStampIsZeroPaddedMonthDayHourMinute(); testUnsetStampProducesNoDiscriminator(); testOrdinaryNameLabelsAndFilesAfterTheTrack(); testTwoCapturesMinutesApartAreDistinguishable(); testNameWithSpacesKeepsThemInTheLabelAndSanitizesInTheStem(); testSurroundingWhitespaceIsTrimmed(); testEmptyNameFallsBackToTheScopeLiteral(); testNoSourceAtAllFallsBackToTheScopeLiteral(); testEmptyNameAndEmptyFallbackStillYieldALegalStem(); testAllWhitespaceNameFallsBackToTheScopeLiteral(); testNameThatTruncatesToNothingFallsBackToCapture(); testUnnamedTrackUsesReaperTrackNConvention(); testAllPunctuationNameKeepsTheLabelAndCollapsesTheStem(); testNonAsciiNameKeepsTheLabelAndCollapsesTheStem(); testMixedAsciiAndNonAsciiKeepsTheAsciiPartInTheStem(); testOverLongNameIsBoundedInBothLabelAndStem(); testOverLongNonAsciiNameIsNotCutMidCharacter(); testTwoTracksWithTheSameNameComposeIdentically(); testMultiTrackSourceMarksTheExtraCount(); testMultiTrackSourceIgnoresUnnamedEntriesInTheCount(); testMultiTrackSourceNamesAfterTheFirstNamedTrack(); testBatchOrdinalDistinguishesUnitsFromOneTrack(); testOrdinalZeroAddsNothing(); testOrdinalAndMultiSourceCompose(); testEveryAwkwardStemStaysFilesystemLegal(); testCaptureTrackNamePrefixesAPlainSourceName(); testCaptureTrackNameIsIdempotent(); testCaptureTrackNameEmptySourceHasNoTrailingSpace(); testCaptureTrackNameUnnamedSourceReadsAsCaptureTrackN(); testCaptureTrackNameDoesNotMatchAMerePrefixOfTheWord(); 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; }