Merge Ε-W2: bank export and bank import, both verbs and both panel rows
Union of two parallel tracks. Both action rows, both menu rows, both link edges survive; the two package CLAUDE.md files now describe the post-merge reality rather than either side's pre-merge scope.
This commit is contained in:
@@ -0,0 +1,396 @@
|
||||
// Standalone tests for shell/package/import_landing — no REAPER, no framework, real
|
||||
// package bytes on a real filesystem. Packages are FRAMED BY HAND (not by
|
||||
// encodePackage) so the version-ladder suites can dial formatVersion and
|
||||
// minReaderVersion independently, and so an encode-side regression cannot hide the
|
||||
// import's behaviour from itself.
|
||||
|
||||
#include "../src/shell/package/import_landing.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../src/core/capture/wav_codec.h"
|
||||
#include "../src/core/package/bank_package.h"
|
||||
#include "../src/core/tracking/origin_ledger.h"
|
||||
#include "../src/core/version/app_version.h"
|
||||
#include "../src/shell/package/package_path.h"
|
||||
|
||||
using namespace reasampler;
|
||||
using namespace reasampler::package;
|
||||
using reasampler::model::Sample;
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
static const char* kTag = "1754000000";
|
||||
static const char* kBankId = "import-test-bank";
|
||||
|
||||
// --- scratch project ---------------------------------------------------------
|
||||
|
||||
// One project directory per suite, torn down after, so no suite can observe another's
|
||||
// bank folder in listFolderFileNames.
|
||||
class Scratch {
|
||||
public:
|
||||
explicit Scratch(const std::string& name)
|
||||
: dir_(pathToUtf8(fs::current_path() / utf8Path("import_scratch_" + name))) {
|
||||
std::error_code ec;
|
||||
fs::remove_all(utf8Path(dir_), ec);
|
||||
fs::create_directories(utf8Path(dir_), ec);
|
||||
}
|
||||
~Scratch() {
|
||||
std::error_code ec;
|
||||
fs::remove_all(utf8Path(dir_), ec);
|
||||
}
|
||||
const std::string& projectDir() const { return dir_; }
|
||||
std::string bankDir() const { return bankFolderDir(dir_); }
|
||||
std::string packagePath() const { return dir_ + "/bank.rsbank"; }
|
||||
|
||||
std::vector<std::string> bankFiles() const {
|
||||
std::vector<std::string> out;
|
||||
std::error_code ec;
|
||||
for (const auto& e : fs::directory_iterator(utf8Path(bankDir()), ec))
|
||||
if (e.is_regular_file(ec)) out.push_back(pathToUtf8(e.path().filename()));
|
||||
return out;
|
||||
}
|
||||
|
||||
private:
|
||||
std::string dir_;
|
||||
};
|
||||
|
||||
static void writeBytes(const std::string& path, const std::vector<std::uint8_t>& bytes) {
|
||||
std::ofstream f(utf8Path(path), std::ios::binary | std::ios::trunc);
|
||||
f.write(reinterpret_cast<const char*>(bytes.data()),
|
||||
static_cast<std::streamsize>(bytes.size()));
|
||||
}
|
||||
|
||||
static std::vector<std::uint8_t> readBytes(const std::string& path) {
|
||||
std::ifstream f(utf8Path(path), std::ios::binary);
|
||||
return std::vector<std::uint8_t>(std::istreambuf_iterator<char>(f),
|
||||
std::istreambuf_iterator<char>());
|
||||
}
|
||||
|
||||
// --- hand-rolled package framing --------------------------------------------
|
||||
|
||||
static void putU32(std::vector<std::uint8_t>& out, std::uint32_t v) {
|
||||
for (int b = 0; b < 4; ++b) out.push_back(static_cast<std::uint8_t>((v >> (b * 8)) & 0xFFu));
|
||||
}
|
||||
|
||||
static std::vector<std::uint8_t> frame(std::uint32_t formatVersion,
|
||||
std::uint32_t minReaderVersion,
|
||||
const std::string& writerSemver,
|
||||
const std::string& manifestJson,
|
||||
const std::vector<std::vector<std::uint8_t>>& payloads) {
|
||||
std::vector<std::uint8_t> out(kPackageMagic, kPackageMagic + 4);
|
||||
putU32(out, formatVersion);
|
||||
putU32(out, minReaderVersion);
|
||||
putU32(out, static_cast<std::uint32_t>(writerSemver.size()));
|
||||
out.insert(out.end(), writerSemver.begin(), writerSemver.end());
|
||||
putU32(out, static_cast<std::uint32_t>(manifestJson.size()));
|
||||
out.insert(out.end(), manifestJson.begin(), manifestJson.end());
|
||||
for (const auto& p : payloads) out.insert(out.end(), p.begin(), p.end());
|
||||
return out;
|
||||
}
|
||||
|
||||
static std::vector<std::uint8_t> payloadOf(std::size_t n, std::uint8_t seed) {
|
||||
std::vector<std::uint8_t> v(n);
|
||||
for (std::size_t i = 0; i < n; ++i) v[i] = static_cast<std::uint8_t>(seed + i * 13u);
|
||||
return v;
|
||||
}
|
||||
|
||||
struct Fixture {
|
||||
PackageManifest manifest;
|
||||
std::vector<std::vector<std::uint8_t>> payloads;
|
||||
};
|
||||
|
||||
static void addEntry(Fixture& f, const std::string& fileName, const std::string& id,
|
||||
const std::string& contentHash, std::uint8_t seed) {
|
||||
std::vector<std::uint8_t> payload = payloadOf(48 + seed, seed);
|
||||
PackageEntry e;
|
||||
e.fileName = fileName;
|
||||
e.byteLength = payload.size();
|
||||
e.byteHash = capture::hashBytes(payload.data(), payload.size());
|
||||
e.sample.id = id;
|
||||
e.sample.displayName = id;
|
||||
e.sample.relativePath = "reasampler_bank/" + fileName;
|
||||
e.sample.contentHash = contentHash;
|
||||
f.manifest.entries.push_back(std::move(e));
|
||||
f.payloads.push_back(std::move(payload));
|
||||
}
|
||||
|
||||
static Fixture twoEntryFixture(const std::string& bankName) {
|
||||
Fixture f;
|
||||
f.manifest.bankDisplayName = bankName;
|
||||
addEntry(f, "kick.wav", "cap-kick", "h-kick", 1);
|
||||
addEntry(f, "snare.wav", "cap-snare", "h-snare", 2);
|
||||
return f;
|
||||
}
|
||||
|
||||
// Frames `f` with this build's own ladder pair unless overridden.
|
||||
static std::vector<std::uint8_t> packageBytes(const Fixture& f,
|
||||
std::uint32_t formatVersion = kPackageFormatVersion,
|
||||
std::uint32_t minReader = kPackageMinReaderVersion,
|
||||
const std::string& manifestOverride = {}) {
|
||||
const auto json = serializeManifest(f.manifest);
|
||||
const std::string body = manifestOverride.empty() ? *json : manifestOverride;
|
||||
return frame(formatVersion, minReader, version::stampVersion(), body, f.payloads);
|
||||
}
|
||||
|
||||
// --- the sequence the verb runs, minus REAPER --------------------------------
|
||||
|
||||
struct RunResult {
|
||||
ImportLanding landing;
|
||||
bool applied = false;
|
||||
tracking::OriginLedger ledger;
|
||||
};
|
||||
|
||||
static RunResult runImport(const Scratch& scratch, BankBook& book,
|
||||
const std::string& bankId = kBankId) {
|
||||
RunResult r;
|
||||
LandedFileJournal journal;
|
||||
r.landing = landPackage(scratch.packagePath(), scratch.projectDir(), book, kTag, journal);
|
||||
if (r.landing.outcome != ImportOutcome::Landed) return r;
|
||||
r.applied = applyImportedBank(book, bankId, r.landing.plan, [&r](const Sample& s) {
|
||||
tracking::OriginRecord rec;
|
||||
rec.relativePath = s.relativePath;
|
||||
rec.kind = tracking::OriginKind::PackageImport;
|
||||
rec.sampleId = s.id;
|
||||
r.ledger.record(rec);
|
||||
});
|
||||
if (r.applied) journal.markIndexCommitted();
|
||||
return r;
|
||||
}
|
||||
|
||||
// --- suites ------------------------------------------------------------------
|
||||
|
||||
static void testCleanImportLandsEveryPayloadByteExact() {
|
||||
Scratch scratch("clean");
|
||||
const Fixture f = twoEntryFixture("Drums");
|
||||
writeBytes(scratch.packagePath(), packageBytes(f));
|
||||
|
||||
BankBook book;
|
||||
const RunResult r = runImport(scratch, book);
|
||||
|
||||
CHECK(r.landing.outcome == ImportOutcome::Landed);
|
||||
CHECK(r.applied);
|
||||
CHECK(scratch.bankFiles().size() == 2);
|
||||
for (std::size_t i = 0; i < 2; ++i) {
|
||||
const std::string name = r.landing.plan.entries[i].destFileName;
|
||||
CHECK(readBytes(scratch.bankDir() + "/" + name) == f.payloads[i]);
|
||||
}
|
||||
const Bank* bank = book.bank(kBankId);
|
||||
CHECK(bank != nullptr && bank->displayName == "Drums");
|
||||
CHECK(bank->index.size() == 2);
|
||||
CHECK(PayloadBuffer::alive() == 0);
|
||||
}
|
||||
|
||||
static void testEveryLandedFileHasAPackageImportBirthRecord() {
|
||||
Scratch scratch("births");
|
||||
const Fixture f = twoEntryFixture("Drums");
|
||||
writeBytes(scratch.packagePath(), packageBytes(f));
|
||||
|
||||
BankBook book;
|
||||
const RunResult r = runImport(scratch, book);
|
||||
CHECK(r.applied);
|
||||
|
||||
// Read the ledger back rather than counting calls.
|
||||
CHECK(r.ledger.size() == 2);
|
||||
for (const std::string& name : scratch.bankFiles()) {
|
||||
const tracking::OriginRecord* rec =
|
||||
r.ledger.find("reasampler_bank/" + name);
|
||||
CHECK(rec != nullptr);
|
||||
if (rec) CHECK(rec->kind == tracking::OriginKind::PackageImport);
|
||||
}
|
||||
}
|
||||
|
||||
static void testACollapsedEntryLeavesNoFileBehind() {
|
||||
Scratch scratch("collapse");
|
||||
Fixture f;
|
||||
f.manifest.bankDisplayName = "Drums";
|
||||
addEntry(f, "kick.wav", "cap-a", "same-hash", 1);
|
||||
addEntry(f, "kick_copy.wav", "cap-b", "same-hash", 2);
|
||||
writeBytes(scratch.packagePath(), packageBytes(f));
|
||||
|
||||
BankBook book;
|
||||
const RunResult r = runImport(scratch, book);
|
||||
CHECK(r.landing.outcome == ImportOutcome::Landed);
|
||||
// One file, one entry, one birth record — the dedup never manufactured an orphan.
|
||||
CHECK(scratch.bankFiles().size() == 1);
|
||||
CHECK(book.bank(kBankId)->index.size() == 1);
|
||||
CHECK(r.ledger.size() == 1);
|
||||
}
|
||||
|
||||
static void testHashMismatchLandsNothingAndMutatesNothing() {
|
||||
Scratch scratch("integrity");
|
||||
const Fixture f = twoEntryFixture("Drums");
|
||||
std::vector<std::uint8_t> bytes = packageBytes(f);
|
||||
bytes.back() ^= 0xFFu; // corrupt the LAST entry's payload
|
||||
writeBytes(scratch.packagePath(), bytes);
|
||||
|
||||
BankBook book;
|
||||
const BankBook before = book;
|
||||
const RunResult r = runImport(scratch, book);
|
||||
|
||||
CHECK(r.landing.outcome == ImportOutcome::IntegrityFailed);
|
||||
CHECK(r.landing.failedEntryName == "snare.wav");
|
||||
// Refused BEFORE landing anything, not landed-then-rolled-back: the bank folder is
|
||||
// created on the way into the write loop, so its absence dates the refusal.
|
||||
CHECK(!fs::exists(utf8Path(scratch.bankDir())));
|
||||
CHECK(r.landing.rollback.deletedCount == 0);
|
||||
CHECK(book == before); // zero index mutation
|
||||
CHECK(!r.applied);
|
||||
}
|
||||
|
||||
static void testWriteFailureAtEntryTwoRollsBackEntryOne() {
|
||||
Scratch scratch("rollback");
|
||||
const Fixture f = twoEntryFixture("Drums");
|
||||
writeBytes(scratch.packagePath(), packageBytes(f));
|
||||
|
||||
// Occupy the second entry's destination with a DIRECTORY: listFolderFileNames sees
|
||||
// regular files only, so the plan does not rename around it, and the exclusive
|
||||
// create then fails exactly where the injection wants it.
|
||||
std::error_code ec;
|
||||
fs::create_directories(utf8Path(scratch.bankDir()), ec);
|
||||
fs::create_directory(utf8Path(scratch.bankDir() + "/snare.wav"), ec);
|
||||
|
||||
BankBook book;
|
||||
const BankBook before = book;
|
||||
const RunResult r = runImport(scratch, book);
|
||||
|
||||
CHECK(r.landing.outcome == ImportOutcome::WriteFailed);
|
||||
CHECK(r.landing.failedEntryName == "snare.wav");
|
||||
CHECK(r.landing.rollback.deletedCount == 1); // entry one was rolled back
|
||||
CHECK(scratch.bankFiles().empty()); // nothing from this import survives
|
||||
CHECK(book == before);
|
||||
CHECK(PayloadBuffer::alive() == 0);
|
||||
}
|
||||
|
||||
static void testTooNewRefusesWholeAndNamesTheWriter() {
|
||||
Scratch scratch("toonew");
|
||||
const Fixture f = twoEntryFixture("Drums");
|
||||
writeBytes(scratch.packagePath(),
|
||||
packageBytes(f, kPackageFormatVersion + 1, kPackageFormatVersion + 1));
|
||||
|
||||
BankBook book;
|
||||
const BankBook before = book;
|
||||
const RunResult r = runImport(scratch, book);
|
||||
|
||||
CHECK(r.landing.outcome == ImportOutcome::TooNew);
|
||||
// All three facts the refusal must name are available from the landing.
|
||||
CHECK(r.landing.header.minReaderVersion == kPackageFormatVersion + 1);
|
||||
CHECK(r.landing.header.writerVersion == version::stampVersion());
|
||||
// The refusal returns before the bank folder is even created.
|
||||
CHECK(!fs::exists(utf8Path(scratch.bankDir())));
|
||||
CHECK(book == before);
|
||||
}
|
||||
|
||||
static void testNewerFormatVersionStillImportsWhenTheReaderIsReachable() {
|
||||
Scratch scratch("additive");
|
||||
const Fixture f = twoEntryFixture("Drums");
|
||||
// An additive newer writer: formatVersion moved, minReaderVersion did not, and the
|
||||
// manifest carries a key this build has never heard of.
|
||||
std::string json = *serializeManifest(f.manifest);
|
||||
CHECK(!json.empty() && json.front() == '{');
|
||||
json.insert(1, "\"futureKey\":{\"nested\":[1,2,3]},");
|
||||
writeBytes(scratch.packagePath(),
|
||||
packageBytes(f, kPackageFormatVersion + 1, kPackageMinReaderVersion, json));
|
||||
|
||||
BankBook book;
|
||||
const RunResult r = runImport(scratch, book);
|
||||
|
||||
CHECK(r.landing.outcome == ImportOutcome::Landed);
|
||||
CHECK(r.landing.header.formatVersion == kPackageFormatVersion + 1);
|
||||
CHECK(book.bank(kBankId)->index.size() == 2);
|
||||
CHECK(scratch.bankFiles().size() == 2);
|
||||
}
|
||||
|
||||
static void testTruncationAndGarbageReportMalformedNotTooNew() {
|
||||
{
|
||||
Scratch scratch("truncated");
|
||||
const Fixture f = twoEntryFixture("Drums");
|
||||
std::vector<std::uint8_t> bytes = packageBytes(f);
|
||||
bytes.pop_back();
|
||||
writeBytes(scratch.packagePath(), bytes);
|
||||
|
||||
BankBook book;
|
||||
const BankBook before = book;
|
||||
const RunResult r = runImport(scratch, book);
|
||||
CHECK(r.landing.outcome == ImportOutcome::Malformed);
|
||||
CHECK(book == before);
|
||||
}
|
||||
{
|
||||
Scratch scratch("garbage");
|
||||
writeBytes(scratch.packagePath(), payloadOf(200, 9));
|
||||
BankBook book;
|
||||
CHECK(runImport(scratch, book).landing.outcome == ImportOutcome::Malformed);
|
||||
}
|
||||
}
|
||||
|
||||
static void testMissingPackageFileIsUnreadableNotMalformed() {
|
||||
Scratch scratch("absent");
|
||||
BankBook book;
|
||||
CHECK(runImport(scratch, book).landing.outcome == ImportOutcome::Unreadable);
|
||||
}
|
||||
|
||||
static void testUnsavedProjectRefusesBeforeAnythingIsRead() {
|
||||
Scratch scratch("noproject");
|
||||
writeBytes(scratch.packagePath(), packageBytes(twoEntryFixture("Drums")));
|
||||
BankBook book;
|
||||
LandedFileJournal journal;
|
||||
const ImportLanding landing =
|
||||
landPackage(scratch.packagePath(), std::string{}, book, kTag, journal);
|
||||
CHECK(landing.outcome == ImportOutcome::NoProject);
|
||||
}
|
||||
|
||||
static void testReimportingIntoTheSourceProjectLandsBesideAnUntouchedOriginal() {
|
||||
Scratch scratch("roundtrip");
|
||||
const Fixture f = twoEntryFixture("B");
|
||||
writeBytes(scratch.packagePath(), packageBytes(f));
|
||||
|
||||
// A project that already holds bank "B" with the package's own entries.
|
||||
BankBook book;
|
||||
book.createBank("bank-b", "B");
|
||||
for (const PackageEntry& e : f.manifest.entries) book.index("bank-b")->add(e.sample);
|
||||
const BankModel originalB = *book.index("bank-b");
|
||||
|
||||
const RunResult second = runImport(scratch, book, "bank-b2");
|
||||
CHECK(second.landing.outcome == ImportOutcome::Landed);
|
||||
CHECK(book.bank("bank-b2")->displayName == "B 2");
|
||||
CHECK(book.bank("bank-b2")->index.size() == 2);
|
||||
CHECK(*book.index("bank-b") == originalB); // B itself unmutated
|
||||
for (const Sample& s : book.index("bank-b2")->all()) {
|
||||
CHECK(s.id.rfind(kImportIdPrefix, 0) == 0);
|
||||
CHECK(s.id != "cap-kick" && s.id != "cap-snare");
|
||||
}
|
||||
|
||||
const RunResult third = runImport(scratch, book, "bank-b3");
|
||||
CHECK(third.landing.outcome == ImportOutcome::Landed);
|
||||
CHECK(book.bank("bank-b3")->displayName == "B 3");
|
||||
CHECK(*book.index("bank-b") == originalB);
|
||||
// Four distinct files: two per re-import, never overwritten. Bank "B"'s own two
|
||||
// entries were seeded index-only above (book.index("bank-b")->add), never written
|
||||
// to disk, so they don't add to this count.
|
||||
CHECK(scratch.bankFiles().size() == 4);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testCleanImportLandsEveryPayloadByteExact();
|
||||
testEveryLandedFileHasAPackageImportBirthRecord();
|
||||
testACollapsedEntryLeavesNoFileBehind();
|
||||
testHashMismatchLandsNothingAndMutatesNothing();
|
||||
testWriteFailureAtEntryTwoRollsBackEntryOne();
|
||||
testTooNewRefusesWholeAndNamesTheWriter();
|
||||
testNewerFormatVersionStillImportsWhenTheReaderIsReachable();
|
||||
testTruncationAndGarbageReportMalformedNotTooNew();
|
||||
testMissingPackageFileIsUnreadableNotMalformed();
|
||||
testUnsavedProjectRefusesBeforeAnythingIsRead();
|
||||
testReimportingIntoTheSourceProjectLandsBesideAnUntouchedOriginal();
|
||||
|
||||
if (g_fail == 0) std::printf("import_landing: all tests passed\n");
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
// Standalone tests for reasampler::package::import_plan — no REAPER, no filesystem,
|
||||
// no test framework. Every one of the four collision classes (sample id, bank-folder
|
||||
// file name, content hash, bank display name) is exercised here, which is the point of
|
||||
// the module: the whole collision rule set is decidable from strings and hashes.
|
||||
|
||||
#include "../src/core/package/import_plan.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../src/core/tracking/tracking_authority.h"
|
||||
|
||||
using namespace reasampler;
|
||||
using namespace reasampler::package;
|
||||
using reasampler::model::Sample;
|
||||
using reasampler::model::SlotMap;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
static const char* kProjectDir = "/proj";
|
||||
static const char* kTag = "1754000000";
|
||||
|
||||
// --- fixtures ----------------------------------------------------------------
|
||||
|
||||
static PackageEntry entry(const std::string& fileName, const std::string& id,
|
||||
const std::string& hash) {
|
||||
PackageEntry e;
|
||||
e.fileName = fileName;
|
||||
e.byteLength = 64;
|
||||
e.byteHash = "0011223344556677";
|
||||
e.sample.id = id;
|
||||
e.sample.displayName = id;
|
||||
e.sample.relativePath = "reasampler_bank/" + fileName;
|
||||
e.sample.contentHash = hash;
|
||||
return e;
|
||||
}
|
||||
|
||||
static PackageManifest manifestOf(std::vector<PackageEntry> entries,
|
||||
const std::string& bankName) {
|
||||
PackageManifest m;
|
||||
m.bankDisplayName = bankName;
|
||||
m.entries = std::move(entries);
|
||||
return m;
|
||||
}
|
||||
|
||||
// A book carrying the named banks, in order, each with a caller-supplied id.
|
||||
static BankBook bookWithBanks(const std::vector<std::string>& names) {
|
||||
BankBook book;
|
||||
for (std::size_t i = 0; i < names.size(); ++i)
|
||||
book.createBank("bank-" + std::to_string(i), names[i]);
|
||||
return book;
|
||||
}
|
||||
|
||||
static const PlannedEntry& landed(const ImportPlan& plan, std::size_t manifestIndex) {
|
||||
return plan.entries[manifestIndex];
|
||||
}
|
||||
|
||||
// --- the bank-name probe (collision class 4) ---------------------------------
|
||||
|
||||
static std::string plannedName(const std::vector<std::string>& existingBanks,
|
||||
const std::string& packageBankName) {
|
||||
const BankBook book = bookWithBanks(existingBanks);
|
||||
return planImport(manifestOf({}, packageBankName), book, kProjectDir, {}, kTag)
|
||||
.bankDisplayName;
|
||||
}
|
||||
|
||||
static void testFreeSeedIsKeptVerbatim() {
|
||||
CHECK(plannedName({"Percussion"}, "Drums") == "Drums");
|
||||
const ImportPlan plan =
|
||||
planImport(manifestOf({}, "Drums"), bookWithBanks({}), kProjectDir, {}, kTag);
|
||||
CHECK(!plan.bankNameAdjusted);
|
||||
CHECK(plan.seedBankName == "Drums");
|
||||
}
|
||||
|
||||
static void testFoldedCollisionTakesTheFirstSuffix() {
|
||||
// The book's fold is case- and whitespace-insensitive, so "drums" blocks "Drums".
|
||||
CHECK(plannedName({"drums"}, "Drums") == "Drums 2");
|
||||
CHECK(plannedName({" DRUMS "}, "Drums") == "Drums 2");
|
||||
|
||||
const ImportPlan plan =
|
||||
planImport(manifestOf({}, "Drums"), bookWithBanks({"drums"}), kProjectDir, {}, kTag);
|
||||
CHECK(plan.bankNameAdjusted);
|
||||
CHECK(plan.seedBankName == "Drums"); // the message needs what was asked for
|
||||
}
|
||||
|
||||
static void testProbeFillsAGap() {
|
||||
// First-free-ascending, not highest-plus-one: "Drums 2" is free, so it wins.
|
||||
CHECK(plannedName({"Drums", "Drums 3"}, "Drums") == "Drums 2");
|
||||
}
|
||||
|
||||
static void testSeedIsNeverReparsed() {
|
||||
// "Drums 2" colliding lands as "Drums 2 2", NOT "Drums 3" — a bare trailing integer
|
||||
// cannot be told from a name the user wrote.
|
||||
CHECK(plannedName({"Drums 2"}, "Drums 2") == "Drums 2 2");
|
||||
CHECK(plannedName({"Kit 808"}, "Kit 808") == "Kit 808 2");
|
||||
}
|
||||
|
||||
static void testBlankRecordedNameFallsBackToTheDefault() {
|
||||
CHECK(plannedName({}, "") == kDefaultImportBankName);
|
||||
CHECK(plannedName({}, " \t ") == kDefaultImportBankName);
|
||||
// And the fallback is a seed like any other, so a second one suffixes.
|
||||
CHECK(plannedName({kDefaultImportBankName}, "") ==
|
||||
std::string(kDefaultImportBankName) + " 2");
|
||||
}
|
||||
|
||||
static void testPoolExportLandsAsANamedBank() {
|
||||
// The destination's pool always exists and always carries the protected name
|
||||
// "Pool", so a pool export imports as a NAMED bank "Pool 2" — intended, not a glitch.
|
||||
CHECK(plannedName({}, "Pool") == "Pool 2");
|
||||
const ImportPlan plan =
|
||||
planImport(manifestOf({}, "Pool"), bookWithBanks({}), kProjectDir, {}, kTag);
|
||||
CHECK(plan.bankNameAdjusted);
|
||||
}
|
||||
|
||||
static void testRepeatedImportsWalkTheSuffixUpwards() {
|
||||
CHECK(plannedName({"B"}, "B") == "B 2");
|
||||
CHECK(plannedName({"B", "B 2"}, "B") == "B 3");
|
||||
}
|
||||
|
||||
// --- sample ids (collision class 1) ------------------------------------------
|
||||
|
||||
static void testEveryIdIsRemintedUnderTheImportPrefix() {
|
||||
const PackageManifest m = manifestOf({entry("kick.wav", "cap-1-kick.wav", "h1"),
|
||||
entry("snare.wav", "cap-2-snare.wav", "h2")},
|
||||
"Drums");
|
||||
const ImportPlan plan = planImport(m, bookWithBanks({}), kProjectDir, {}, kTag);
|
||||
|
||||
CHECK(plan.landCount == 2);
|
||||
for (const PlannedEntry& e : plan.entries) {
|
||||
CHECK(e.sample.id.rfind(kImportIdPrefix, 0) == 0);
|
||||
CHECK(e.sample.id != "cap-1-kick.wav");
|
||||
CHECK(e.sample.id != "cap-2-snare.wav");
|
||||
}
|
||||
CHECK(plan.entries[0].sample.id != plan.entries[1].sample.id);
|
||||
}
|
||||
|
||||
static void testReimportingIntoTheSourceProjectRemintsRatherThanCollides() {
|
||||
// The package came FROM this project, so its ids are the ones already in use.
|
||||
BankBook book = bookWithBanks({"B"});
|
||||
Sample existing;
|
||||
existing.id = "cap-1-kick.wav";
|
||||
existing.relativePath = "reasampler_bank/kick.wav";
|
||||
existing.contentHash = "h1";
|
||||
book.index("bank-0")->add(existing);
|
||||
|
||||
const PackageManifest m = manifestOf({entry("kick.wav", "cap-1-kick.wav", "h1")}, "B");
|
||||
const ImportPlan plan =
|
||||
planImport(m, book, kProjectDir, {"kick.wav"}, kTag);
|
||||
|
||||
CHECK(plan.bankDisplayName == "B 2");
|
||||
CHECK(landed(plan, 0).sample.id != "cap-1-kick.wav");
|
||||
// The hash lives in another bank; cross-bank dedup is deliberately not enforced,
|
||||
// so the entry still lands rather than collapsing onto B's copy.
|
||||
CHECK(landed(plan, 0).action == EntryAction::Land);
|
||||
CHECK(landed(plan, 0).renamed);
|
||||
}
|
||||
|
||||
static void testParentIsRemappedWhenItTravelledInThePackage() {
|
||||
PackageEntry parent = entry("kick.wav", "cap-parent", "h1");
|
||||
PackageEntry child = entry("kick_r2.wav", "cap-child", "h2");
|
||||
child.sample.provenance = model::Provenance{"cap-parent", "fx-snapshot"};
|
||||
|
||||
const ImportPlan plan = planImport(manifestOf({parent, child}, "Drums"),
|
||||
bookWithBanks({}), kProjectDir, {}, kTag);
|
||||
|
||||
CHECK(landed(plan, 1).sample.provenance.has_value());
|
||||
CHECK(landed(plan, 1).sample.provenance->parentSampleId ==
|
||||
landed(plan, 0).sample.id);
|
||||
CHECK(landed(plan, 1).sample.provenance->fxChainSnapshot == "fx-snapshot");
|
||||
}
|
||||
|
||||
static void testParentIsRemappedEvenWhenItFollowsTheChild() {
|
||||
// Manifest order does not constrain lineage, so the remap runs after every id is minted.
|
||||
PackageEntry child = entry("kick_r2.wav", "cap-child", "h2");
|
||||
child.sample.provenance = model::Provenance{"cap-parent", ""};
|
||||
PackageEntry parent = entry("kick.wav", "cap-parent", "h1");
|
||||
|
||||
const ImportPlan plan = planImport(manifestOf({child, parent}, "Drums"),
|
||||
bookWithBanks({}), kProjectDir, {}, kTag);
|
||||
CHECK(landed(plan, 0).sample.provenance->parentSampleId == landed(plan, 1).sample.id);
|
||||
}
|
||||
|
||||
static void testForeignParentIsClearedNotCarried() {
|
||||
PackageEntry child = entry("kick.wav", "cap-child", "h1");
|
||||
child.sample.provenance = model::Provenance{"cap-not-in-this-package", "fx"};
|
||||
|
||||
const ImportPlan plan = planImport(manifestOf({child}, "Drums"),
|
||||
bookWithBanks({}), kProjectDir, {}, kTag);
|
||||
CHECK(landed(plan, 0).sample.provenance.has_value());
|
||||
CHECK(landed(plan, 0).sample.provenance->parentSampleId.empty());
|
||||
CHECK(landed(plan, 0).sample.provenance->fxChainSnapshot == "fx");
|
||||
}
|
||||
|
||||
// --- bank-folder file names (collision class 2) ------------------------------
|
||||
|
||||
static void testAFreeBankLegalNameIsKept() {
|
||||
const ImportPlan plan = planImport(manifestOf({entry("kick.wav", "a", "h1")}, "Drums"),
|
||||
bookWithBanks({}), kProjectDir,
|
||||
{"unrelated.wav"}, kTag);
|
||||
CHECK(landed(plan, 0).destFileName == "kick.wav");
|
||||
CHECK(!landed(plan, 0).renamed);
|
||||
CHECK(plan.collisionRenameCount == 0);
|
||||
CHECK(plan.sanitizeRenameCount == 0);
|
||||
CHECK(landed(plan, 0).sample.relativePath == "reasampler_bank/kick.wav");
|
||||
}
|
||||
|
||||
static void testATakenNameIsMintedFreshAndNeverOverwritten() {
|
||||
const ImportPlan plan = planImport(manifestOf({entry("kick.wav", "a", "h1")}, "Drums"),
|
||||
bookWithBanks({}), kProjectDir,
|
||||
{"kick.wav"}, kTag);
|
||||
CHECK(landed(plan, 0).destFileName != "kick.wav");
|
||||
CHECK(landed(plan, 0).renamed);
|
||||
// A genuine folder-name collision, not a spelling mint.
|
||||
CHECK(plan.collisionRenameCount == 1);
|
||||
CHECK(plan.sanitizeRenameCount == 0);
|
||||
CHECK(landed(plan, 0).sample.relativePath ==
|
||||
"reasampler_bank/" + landed(plan, 0).destFileName);
|
||||
}
|
||||
|
||||
static void testTheFolderNameCheckFoldsAsciiCase() {
|
||||
// Windows and the default APFS would land "kick.wav" onto "KICK.WAV".
|
||||
const ImportPlan plan = planImport(manifestOf({entry("kick.wav", "a", "h1")}, "Drums"),
|
||||
bookWithBanks({}), kProjectDir,
|
||||
{"KICK.WAV"}, kTag);
|
||||
CHECK(landed(plan, 0).destFileName != "kick.wav");
|
||||
CHECK(landed(plan, 0).renamed);
|
||||
// The case-fold hit is still a collision, not a spelling mint.
|
||||
CHECK(plan.collisionRenameCount == 1);
|
||||
CHECK(plan.sanitizeRenameCount == 0);
|
||||
}
|
||||
|
||||
static void testTwoEntriesNeverLandOnOneName() {
|
||||
// Two package names that differ only by case are one destination file.
|
||||
const ImportPlan plan =
|
||||
planImport(manifestOf({entry("kick.wav", "a", "h1"), entry("Kick.wav", "b", "h2")},
|
||||
"Drums"),
|
||||
bookWithBanks({}), kProjectDir, {}, kTag);
|
||||
CHECK(plan.landCount == 2);
|
||||
CHECK(landed(plan, 0).destFileName != landed(plan, 1).destFileName);
|
||||
}
|
||||
|
||||
static void testAnUnsanitaryNameIsMintedRatherThanLandedVerbatim() {
|
||||
const ImportPlan plan =
|
||||
planImport(manifestOf({entry("Hit One.wav", "a", "h1")}, "Drums"),
|
||||
bookWithBanks({}), kProjectDir, {}, kTag);
|
||||
CHECK(landed(plan, 0).destFileName.find(' ') == std::string::npos);
|
||||
CHECK(landed(plan, 0).renamed);
|
||||
// No collision here (the bank folder is empty) — this is a sanitize mint.
|
||||
CHECK(plan.sanitizeRenameCount == 1);
|
||||
CHECK(plan.collisionRenameCount == 0);
|
||||
}
|
||||
|
||||
// --- content hash (collision class 3) ----------------------------------------
|
||||
|
||||
static void testAnAlreadyLandedHashCollapsesWithoutAWrite() {
|
||||
const ImportPlan plan =
|
||||
planImport(manifestOf({entry("kick.wav", "a", "same"),
|
||||
entry("kick_copy.wav", "b", "same")},
|
||||
"Drums"),
|
||||
bookWithBanks({}), kProjectDir, {}, kTag);
|
||||
|
||||
CHECK(plan.landCount == 1);
|
||||
CHECK(plan.collapseCount == 1);
|
||||
CHECK(landed(plan, 0).action == EntryAction::Land);
|
||||
CHECK(landed(plan, 1).action == EntryAction::Collapse);
|
||||
// No name is claimed for it — a dedup that wrote a file would manufacture an orphan.
|
||||
CHECK(landed(plan, 1).destFileName.empty());
|
||||
// Every manifest entry still yields exactly one planned entry: planImport is total.
|
||||
CHECK(plan.entries.size() == 2);
|
||||
}
|
||||
|
||||
static void testAParentPointingAtACollapsedEntryResolvesToTheSurvivor() {
|
||||
PackageEntry first = entry("kick.wav", "cap-first", "same");
|
||||
PackageEntry dupe = entry("kick_copy.wav", "cap-dupe", "same");
|
||||
PackageEntry child = entry("kick_r2.wav", "cap-child", "other");
|
||||
child.sample.provenance = model::Provenance{"cap-dupe", ""};
|
||||
|
||||
const ImportPlan plan = planImport(manifestOf({first, dupe, child}, "Drums"),
|
||||
bookWithBanks({}), kProjectDir, {}, kTag);
|
||||
CHECK(landed(plan, 2).sample.provenance->parentSampleId == landed(plan, 0).sample.id);
|
||||
}
|
||||
|
||||
static void testAnEmptyHashNeverCollapses() {
|
||||
// Mirrors findByHash: an unhashable entry does not participate in dedup.
|
||||
const ImportPlan plan =
|
||||
planImport(manifestOf({entry("a.wav", "a", ""), entry("b.wav", "b", "")}, "Drums"),
|
||||
bookWithBanks({}), kProjectDir, {}, kTag);
|
||||
CHECK(plan.landCount == 2);
|
||||
CHECK(plan.collapseCount == 0);
|
||||
}
|
||||
|
||||
// --- slots -------------------------------------------------------------------
|
||||
|
||||
static void testSlotsRideAlongOverTheRemintedIds() {
|
||||
PackageManifest m = manifestOf({entry("kick.wav", "cap-a", "h1"),
|
||||
entry("snare.wav", "cap-b", "h2")},
|
||||
"Drums");
|
||||
// A gap the package carried: slot 0 empty, occupants at 1 and 3.
|
||||
m.slots = SlotMap::fromEntries({{"cap-a", 1}, {"cap-b", 3}});
|
||||
|
||||
const ImportPlan plan = planImport(m, bookWithBanks({}), kProjectDir, {}, kTag);
|
||||
CHECK(plan.slots.slotOf(landed(plan, 0).sample.id) == 1);
|
||||
CHECK(plan.slots.slotOf(landed(plan, 1).sample.id) == 3);
|
||||
// The package's own ids are gone from the map — a foreign id never enters the index.
|
||||
CHECK(plan.slots.slotOf("cap-a") == -1);
|
||||
}
|
||||
|
||||
static void testACollapsedEntryDoesNotDoubleOccupyASlot() {
|
||||
PackageManifest m = manifestOf({entry("kick.wav", "cap-a", "same"),
|
||||
entry("kick_copy.wav", "cap-b", "same")},
|
||||
"Drums");
|
||||
m.slots = SlotMap::fromEntries({{"cap-a", 0}, {"cap-b", 1}});
|
||||
|
||||
const ImportPlan plan = planImport(m, bookWithBanks({}), kProjectDir, {}, kTag);
|
||||
CHECK(plan.slots.size() == 1);
|
||||
CHECK(plan.slots.slotOf(landed(plan, 0).sample.id) == 0);
|
||||
}
|
||||
|
||||
// --- the ledger gate ---------------------------------------------------------
|
||||
|
||||
static void testDegradedLedgerStatusesRefuseAndTheUsableOnesDoNot() {
|
||||
CHECK(importLedgerRefusal(tracking::LedgerStatus::Unreadable) ==
|
||||
LedgerRefusal::Malformed);
|
||||
CHECK(importLedgerRefusal(tracking::LedgerStatus::FutureVersion) ==
|
||||
LedgerRefusal::FutureVersion);
|
||||
CHECK(importLedgerRefusal(tracking::LedgerStatus::Fresh) == LedgerRefusal::None);
|
||||
CHECK(importLedgerRefusal(tracking::LedgerStatus::Loaded) == LedgerRefusal::None);
|
||||
}
|
||||
|
||||
static void testAnUndecodableUsageKeyBlocksPruneButNotImport() {
|
||||
// The tempting reuse of PruneReport::blockedByTracking would silently refuse an
|
||||
// import over a key that only ever governs what a DELETION may touch.
|
||||
tracking::OriginLedger ledger;
|
||||
wire::UsageFoldResult usage;
|
||||
usage.abortPrune = true;
|
||||
usage.offendingKeys = {"rsusage_{ABC}"};
|
||||
|
||||
const tracking::TrackingState state{tracking::LedgerStatus::Loaded, ledger, usage};
|
||||
CHECK(tracking::pruneProtection(state).blocked);
|
||||
CHECK(importLedgerRefusal(tracking::LedgerStatus::Loaded) == LedgerRefusal::None);
|
||||
}
|
||||
|
||||
// Pins the delegation itself: importLedgerRefusal must refuse EXACTLY the statuses
|
||||
// ledgerDegraded() calls degraded, over every value the enum has today. A gate that
|
||||
// re-derived its own notion of "degraded" could silently diverge from this the moment
|
||||
// either side changes without the other.
|
||||
static void testImportLedgerRefusalDelegatesToLedgerDegraded() {
|
||||
const tracking::LedgerStatus all[] = {
|
||||
tracking::LedgerStatus::Fresh,
|
||||
tracking::LedgerStatus::Loaded,
|
||||
tracking::LedgerStatus::Unreadable,
|
||||
tracking::LedgerStatus::FutureVersion,
|
||||
};
|
||||
for (tracking::LedgerStatus s : all)
|
||||
CHECK((importLedgerRefusal(s) != LedgerRefusal::None) == tracking::ledgerDegraded(s));
|
||||
}
|
||||
|
||||
// --- the ledger-refusal message (pure, so both channels are assertable without a DAW) --
|
||||
|
||||
static void testLedgerRefusalMessageIsEmptyForNone() {
|
||||
CHECK(ledgerRefusalMessage(LedgerRefusal::None, "reasampler").empty());
|
||||
}
|
||||
|
||||
static void testLedgerRefusalMessageNamesTheChannelCorrectNamespace() {
|
||||
// The two real namespaces (app_version.h): stable "reasampler", beta "reasampler_beta".
|
||||
const std::string stable = ledgerRefusalMessage(LedgerRefusal::Malformed, "reasampler");
|
||||
CHECK(stable.find("\"reasampler\"") != std::string::npos);
|
||||
CHECK(stable.find("reasampler_beta") == std::string::npos);
|
||||
|
||||
const std::string beta = ledgerRefusalMessage(LedgerRefusal::Malformed, "reasampler_beta");
|
||||
CHECK(beta.find("\"reasampler_beta\"") != std::string::npos);
|
||||
}
|
||||
|
||||
static void testLedgerRefusalMessageDistinguishesMalformedFromFutureVersion() {
|
||||
const std::string malformed = ledgerRefusalMessage(LedgerRefusal::Malformed, "reasampler");
|
||||
const std::string futureVersion =
|
||||
ledgerRefusalMessage(LedgerRefusal::FutureVersion, "reasampler");
|
||||
CHECK(malformed != futureVersion);
|
||||
CHECK(malformed.find("malformed") != std::string::npos);
|
||||
CHECK(futureVersion.find("NEWER version") != std::string::npos);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testFreeSeedIsKeptVerbatim();
|
||||
testFoldedCollisionTakesTheFirstSuffix();
|
||||
testProbeFillsAGap();
|
||||
testSeedIsNeverReparsed();
|
||||
testBlankRecordedNameFallsBackToTheDefault();
|
||||
testPoolExportLandsAsANamedBank();
|
||||
testRepeatedImportsWalkTheSuffixUpwards();
|
||||
|
||||
testEveryIdIsRemintedUnderTheImportPrefix();
|
||||
testReimportingIntoTheSourceProjectRemintsRatherThanCollides();
|
||||
testParentIsRemappedWhenItTravelledInThePackage();
|
||||
testParentIsRemappedEvenWhenItFollowsTheChild();
|
||||
testForeignParentIsClearedNotCarried();
|
||||
|
||||
testAFreeBankLegalNameIsKept();
|
||||
testATakenNameIsMintedFreshAndNeverOverwritten();
|
||||
testTheFolderNameCheckFoldsAsciiCase();
|
||||
testTwoEntriesNeverLandOnOneName();
|
||||
testAnUnsanitaryNameIsMintedRatherThanLandedVerbatim();
|
||||
|
||||
testAnAlreadyLandedHashCollapsesWithoutAWrite();
|
||||
testAParentPointingAtACollapsedEntryResolvesToTheSurvivor();
|
||||
testAnEmptyHashNeverCollapses();
|
||||
|
||||
testSlotsRideAlongOverTheRemintedIds();
|
||||
testACollapsedEntryDoesNotDoubleOccupyASlot();
|
||||
|
||||
testDegradedLedgerStatusesRefuseAndTheUsableOnesDoNot();
|
||||
testAnUndecodableUsageKeyBlocksPruneButNotImport();
|
||||
testImportLedgerRefusalDelegatesToLedgerDegraded();
|
||||
|
||||
testLedgerRefusalMessageIsEmptyForNone();
|
||||
testLedgerRefusalMessageNamesTheChannelCorrectNamespace();
|
||||
testLedgerRefusalMessageDistinguishesMalformedFromFutureVersion();
|
||||
|
||||
if (g_fail == 0) std::printf("import_plan: all tests passed\n");
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
Reference in New Issue
Block a user