package: one bank leaves the project as one .rsbank, or the export refuses and says why
Pure planner classifies missing/unreadable/unrepresentable and repairs transport names; the verb digests, streams and commits atomically over a const session.
This commit is contained in:
@@ -0,0 +1,375 @@
|
||||
// Standalone tests for shell/package/export_bank — no REAPER, no framework. The
|
||||
// export reads the session through inline accessors only, so a real ReaSamplerSession
|
||||
// and a real bank folder on disk are both constructible here.
|
||||
//
|
||||
// Mid-stream failure is INJECTED rather than simulated: the digest pass and the
|
||||
// stream pass are separate public calls, so a source file removed or rewritten
|
||||
// between them is exactly the concurrent-edit case the stream pass re-checks for.
|
||||
|
||||
#include "../src/shell/package/export_bank.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../src/core/capture/wav_codec.h"
|
||||
#include "../src/core/model/bank_book.h"
|
||||
#include "../src/core/package/bank_package.h"
|
||||
#include "../src/shell/package/package_io.h"
|
||||
#include "../src/shell/package/package_path.h"
|
||||
#include "../src/shell/persist/session.h"
|
||||
|
||||
using namespace reasampler;
|
||||
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)
|
||||
|
||||
// --- scratch filesystem -------------------------------------------------------
|
||||
|
||||
static std::string g_root;
|
||||
|
||||
static std::string scratchRoot() {
|
||||
if (g_root.empty()) {
|
||||
std::error_code ec;
|
||||
const fs::path p = fs::temp_directory_path(ec) / "reasampler_export_tests";
|
||||
fs::remove_all(p, ec);
|
||||
fs::create_directories(p, ec);
|
||||
g_root = p.generic_string();
|
||||
}
|
||||
return g_root;
|
||||
}
|
||||
|
||||
static std::vector<std::uint8_t> patternBytes(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 * 7u);
|
||||
return v;
|
||||
}
|
||||
|
||||
static void writeFile(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> readFile(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>());
|
||||
}
|
||||
|
||||
static bool exists(const std::string& path) { return fs::exists(utf8Path(path)); }
|
||||
|
||||
// --- fixture project ----------------------------------------------------------
|
||||
|
||||
// One scratch project directory with a bank folder, plus the session whose book
|
||||
// names its contents. `fileNames` are written into the bank folder with distinct
|
||||
// byte patterns; a name in `omit` gets an index entry but NO file on disk.
|
||||
struct Fixture {
|
||||
std::string projectDir;
|
||||
ReaSamplerSession session;
|
||||
std::string bankId = "bank-1";
|
||||
|
||||
explicit Fixture(const std::string& tag) {
|
||||
projectDir = scratchRoot() + "/" + tag;
|
||||
std::error_code ec;
|
||||
fs::create_directories(utf8Path(projectDir + "/reasampler_bank"), ec);
|
||||
session.book().createBank(bankId, "Drums " + tag);
|
||||
}
|
||||
|
||||
void addSample(const std::string& id, const std::string& fileName,
|
||||
std::size_t bytes, std::uint8_t seed, bool writeToDisk = true) {
|
||||
model::Sample s;
|
||||
s.id = id;
|
||||
s.displayName = id;
|
||||
s.relativePath = std::string("reasampler_bank/") + fileName;
|
||||
s.sampleRate = 48000;
|
||||
s.channelCount = 2;
|
||||
s.contentHash = "hash-" + id;
|
||||
CHECK(session.book().index(bankId)->add(s) == model::AddResult::Added);
|
||||
session.book().reconcileSlots();
|
||||
if (writeToDisk) writeFile(absPathOf(fileName), patternBytes(bytes, seed));
|
||||
}
|
||||
|
||||
std::string absPathOf(const std::string& fileName) const {
|
||||
return projectDir + "/reasampler_bank/" + fileName;
|
||||
}
|
||||
std::string destPath() const { return projectDir + "/out.rsbank"; }
|
||||
|
||||
ExportRequest request() const {
|
||||
ExportRequest req;
|
||||
req.projectDir = projectDir;
|
||||
req.bankId = bankId;
|
||||
req.destAbsPath = destPath();
|
||||
req.exportTimestamp = 1234567890;
|
||||
return req;
|
||||
}
|
||||
};
|
||||
|
||||
// --- package readback ---------------------------------------------------------
|
||||
|
||||
// Decodes an emitted package straight off disk, growing the prefix read the way the
|
||||
// format's own requiredPrefixSize seam asks callers to.
|
||||
static package::DecodedPackage decodeFromDisk(const std::string& path) {
|
||||
PackageFileReader reader(path);
|
||||
const std::uint64_t size = reader.fileSize();
|
||||
std::vector<std::uint8_t> prefix;
|
||||
for (int guard = 0; guard < 8; ++guard) {
|
||||
const std::optional<std::uint64_t> need = package::requiredPrefixSize(prefix);
|
||||
if (!need) break;
|
||||
if (*need <= prefix.size()) break;
|
||||
PayloadBuffer buf = reader.readRange(0, *need);
|
||||
if (buf.empty()) break;
|
||||
prefix.assign(buf.data(), buf.data() + buf.size());
|
||||
}
|
||||
return package::decodePackage(prefix, size);
|
||||
}
|
||||
|
||||
// --- tests --------------------------------------------------------------------
|
||||
|
||||
static void testHealthyExportCarriesEveryPayloadByteExact() {
|
||||
Fixture fx("healthy");
|
||||
fx.addSample("s1", "kick.wav", 800, 1);
|
||||
fx.addSample("s2", "snare.wav", 1300, 60);
|
||||
fx.addSample("s3", "hat.wav", 97, 200);
|
||||
|
||||
const ExportOutcome out = exportBank(fx.session, fx.request());
|
||||
CHECK(out.status == ExportStatus::Written);
|
||||
CHECK(out.entriesWritten == 3);
|
||||
CHECK(out.excluded.empty());
|
||||
CHECK(PayloadBuffer::alive() == 0);
|
||||
|
||||
const package::DecodedPackage decoded = decodeFromDisk(fx.destPath());
|
||||
CHECK(decoded.status == package::PackageReadability::Readable);
|
||||
CHECK(decoded.manifest.entries.size() == 3);
|
||||
CHECK(decoded.layout.size() == 3);
|
||||
CHECK(decoded.manifest.bankDisplayName == "Drums healthy");
|
||||
CHECK(decoded.manifest.exportTimestamp == 1234567890);
|
||||
|
||||
// PER ENTRY, not in aggregate: the digest the package records, the digest of the
|
||||
// payload actually stored at that entry's span, and the digest of the source file
|
||||
// on disk must all be the same string.
|
||||
PackageFileReader reader(fx.destPath());
|
||||
const std::vector<std::string> sourceNames = {"kick.wav", "snare.wav", "hat.wav"};
|
||||
CHECK(decoded.manifest.entries.size() == sourceNames.size());
|
||||
for (std::size_t i = 0; i < decoded.manifest.entries.size(); ++i) {
|
||||
const package::PackageEntry& entry = decoded.manifest.entries[i];
|
||||
const PayloadBuffer stored = reader.readRange(decoded.layout[i].offset,
|
||||
decoded.layout[i].length);
|
||||
CHECK(!stored.empty());
|
||||
const std::vector<std::uint8_t> source = readFile(fx.absPathOf(sourceNames[i]));
|
||||
const std::string sourceDigest = capture::hashBytes(source.data(), source.size());
|
||||
CHECK(entry.byteLength == source.size());
|
||||
CHECK(entry.byteHash == sourceDigest);
|
||||
CHECK(capture::hashBytes(stored.data(), stored.size()) == sourceDigest);
|
||||
CHECK(stored.size() == source.size());
|
||||
CHECK(std::equal(source.begin(), source.end(), stored.data()));
|
||||
}
|
||||
CHECK(PayloadBuffer::alive() == 0);
|
||||
}
|
||||
|
||||
static void testEmittedManifestBytesCarryNoPath() {
|
||||
Fixture fx("nopath");
|
||||
// Both source records carry a directory component; neither may reach the file.
|
||||
fx.addSample("s1", "kick.wav", 200, 3);
|
||||
fx.addSample("s2", "snare take 2.wav", 200, 9);
|
||||
CHECK(exportBank(fx.session, fx.request()).status == ExportStatus::Written);
|
||||
|
||||
// Scan the MANIFEST REGION of the emitted file, located by walking the frozen
|
||||
// header the way a reader does: magic | fv | minReader | len+semver | len+JSON.
|
||||
// The binary length fields are deliberately excluded — a length whose byte
|
||||
// happens to be 0x2F is not a separator.
|
||||
const std::vector<std::uint8_t> file = readFile(fx.destPath());
|
||||
CHECK(file.size() > 20);
|
||||
auto le32 = [&](std::size_t at) {
|
||||
return static_cast<std::uint32_t>(file[at]) |
|
||||
(static_cast<std::uint32_t>(file[at + 1]) << 8) |
|
||||
(static_cast<std::uint32_t>(file[at + 2]) << 16) |
|
||||
(static_cast<std::uint32_t>(file[at + 3]) << 24);
|
||||
};
|
||||
const std::uint32_t semverLen = le32(12);
|
||||
const std::size_t manifestLenAt = 16 + semverLen;
|
||||
const std::uint32_t manifestLen = le32(manifestLenAt);
|
||||
const std::size_t manifestAt = manifestLenAt + 4;
|
||||
CHECK(manifestAt + manifestLen <= file.size());
|
||||
const std::string manifest(reinterpret_cast<const char*>(file.data() + manifestAt),
|
||||
manifestLen);
|
||||
|
||||
CHECK(manifest.find("kick.wav") != std::string::npos); // the scan is looking at the manifest
|
||||
CHECK(manifest.find('/') == std::string::npos);
|
||||
CHECK(manifest.find('\\') == std::string::npos);
|
||||
CHECK(manifest.find("..") == std::string::npos);
|
||||
CHECK(manifest.find("reasampler_bank") == std::string::npos);
|
||||
// ':' cannot be banned outright — it is JSON's own key separator — so the check
|
||||
// is for the drive form specifically: a string value opening with <alpha>':'.
|
||||
// With '/' and '\\' already absent, that covers the drive-relative spelling too.
|
||||
bool driveForm = false;
|
||||
for (std::size_t i = 0; i + 2 < manifest.size(); ++i)
|
||||
if (manifest[i] == '"' &&
|
||||
std::isalpha(static_cast<unsigned char>(manifest[i + 1])) &&
|
||||
manifest[i + 2] == ':')
|
||||
driveForm = true;
|
||||
CHECK(!driveForm);
|
||||
}
|
||||
|
||||
static void testFailureInjectedMidWriteLeavesNoPackageAndSparesThePriorFile() {
|
||||
Fixture fx("midwrite");
|
||||
fx.addSample("s1", "kick.wav", 500, 1);
|
||||
fx.addSample("s2", "snare.wav", 500, 2);
|
||||
|
||||
const std::vector<std::uint8_t> prior = patternBytes(64, 99);
|
||||
writeFile(fx.destPath(), prior);
|
||||
|
||||
ExportSurvey survey = surveyBankExport(fx.session, fx.projectDir, fx.bankId);
|
||||
CHECK(survey.plan.verdict == package::ExportVerdict::Ready);
|
||||
package::PackageManifest manifest = survey.plan.manifest;
|
||||
const std::vector<std::string> sources = {fx.absPathOf("kick.wav"),
|
||||
fx.absPathOf("snare.wav")};
|
||||
std::string failed;
|
||||
CHECK(digestSources(manifest, sources, failed));
|
||||
const std::optional<package::EncodedPackage> encoded = package::encodePackage(manifest);
|
||||
CHECK(encoded.has_value());
|
||||
|
||||
// Injection: the second payload vanishes after the framing that claims it was
|
||||
// already encoded, so the failure lands with the prefix and one payload written.
|
||||
std::error_code ec;
|
||||
fs::remove(utf8Path(fx.absPathOf("snare.wav")), ec);
|
||||
|
||||
const ExportOutcome out =
|
||||
writePackageFile(*encoded, manifest, sources, fx.destPath());
|
||||
CHECK(out.status == ExportStatus::SourceReadFailed);
|
||||
CHECK(out.offendingName == "snare.wav");
|
||||
CHECK(readFile(fx.destPath()) == prior); // the prior file is untouched
|
||||
CHECK(!exists(fx.destPath() + ".rsbanktmp")); // and no debris is left behind
|
||||
CHECK(PayloadBuffer::alive() == 0);
|
||||
}
|
||||
|
||||
static void testPayloadChangedBetweenDigestAndStreamAborts() {
|
||||
Fixture fx("changed");
|
||||
fx.addSample("s1", "kick.wav", 500, 1);
|
||||
CHECK(!exists(fx.destPath()));
|
||||
|
||||
ExportSurvey survey = surveyBankExport(fx.session, fx.projectDir, fx.bankId);
|
||||
package::PackageManifest manifest = survey.plan.manifest;
|
||||
const std::vector<std::string> sources = {fx.absPathOf("kick.wav")};
|
||||
std::string failed;
|
||||
CHECK(digestSources(manifest, sources, failed));
|
||||
const std::optional<package::EncodedPackage> encoded = package::encodePackage(manifest);
|
||||
CHECK(encoded.has_value());
|
||||
|
||||
// Same length, different bytes — only the digest re-check can catch this.
|
||||
writeFile(fx.absPathOf("kick.wav"), patternBytes(500, 77));
|
||||
|
||||
const ExportOutcome out =
|
||||
writePackageFile(*encoded, manifest, sources, fx.destPath());
|
||||
CHECK(out.status == ExportStatus::SourceChanged);
|
||||
CHECK(out.offendingName == "kick.wav");
|
||||
CHECK(!exists(fx.destPath()));
|
||||
}
|
||||
|
||||
static void testExportTouchesNoProjectState() {
|
||||
Fixture fx("readonly");
|
||||
fx.addSample("s1", "kick.wav", 400, 5);
|
||||
fx.addSample("s2", "snare.wav", 400, 6);
|
||||
|
||||
// The ext-state blob IS the serialized book (shell/persist/ext_state_io), so
|
||||
// byte-identity of that string is byte-identity of what a persist would write.
|
||||
const std::string extStateBefore = fx.session.book().serialize();
|
||||
const std::int64_t generationBefore = fx.session.bankGeneration();
|
||||
|
||||
CHECK(exportBank(fx.session, fx.request()).status == ExportStatus::Written);
|
||||
|
||||
CHECK(fx.session.book().serialize() == extStateBefore);
|
||||
CHECK(fx.session.bankGeneration() == generationBefore);
|
||||
}
|
||||
|
||||
static void testEmptyBankExportsAsAValidZeroEntryPackage() {
|
||||
Fixture fx("empty");
|
||||
const ExportOutcome out = exportBank(fx.session, fx.request());
|
||||
CHECK(out.status == ExportStatus::Written);
|
||||
CHECK(out.entriesWritten == 0);
|
||||
|
||||
const package::DecodedPackage decoded = decodeFromDisk(fx.destPath());
|
||||
CHECK(decoded.status == package::PackageReadability::Readable);
|
||||
CHECK(decoded.manifest.entries.empty());
|
||||
CHECK(decoded.layout.empty());
|
||||
CHECK(decoded.manifest.bankDisplayName == "Drums empty");
|
||||
// The size proof is decodePackage's, and it ran against the real on-disk size.
|
||||
CHECK(decoded.prefixSize == readFile(fx.destPath()).size());
|
||||
}
|
||||
|
||||
static void testIncompleteBankRefusesUntilConfirmed() {
|
||||
Fixture fx("incomplete");
|
||||
fx.addSample("s1", "kick.wav", 300, 1);
|
||||
fx.addSample("s2", "gone.wav", 300, 2, /*writeToDisk=*/false);
|
||||
|
||||
ExportRequest req = fx.request();
|
||||
const ExportOutcome refused = exportBank(fx.session, req);
|
||||
CHECK(refused.status == ExportStatus::RefusedIncomplete);
|
||||
CHECK(refused.excluded.size() == 1);
|
||||
CHECK(refused.excluded[0].sampleId == "s2");
|
||||
CHECK(refused.excluded[0].reason == package::ExclusionReason::FileMissing);
|
||||
CHECK(!exists(fx.destPath()));
|
||||
|
||||
req.allowIncomplete = true;
|
||||
const ExportOutcome allowed = exportBank(fx.session, req);
|
||||
CHECK(allowed.status == ExportStatus::Written);
|
||||
CHECK(allowed.entriesWritten == 1);
|
||||
CHECK(allowed.excluded.size() == 1); // the report survives into the summary
|
||||
CHECK(decodeFromDisk(fx.destPath()).manifest.entries.size() == 1);
|
||||
}
|
||||
|
||||
static void testExistingDestinationRefusesUntilConfirmed() {
|
||||
Fixture fx("overwrite");
|
||||
fx.addSample("s1", "kick.wav", 300, 1);
|
||||
const std::vector<std::uint8_t> prior = patternBytes(32, 11);
|
||||
writeFile(fx.destPath(), prior);
|
||||
|
||||
ExportRequest req = fx.request();
|
||||
const ExportOutcome refused = exportBank(fx.session, req);
|
||||
CHECK(refused.status == ExportStatus::RefusedDestinationExists);
|
||||
CHECK(readFile(fx.destPath()) == prior);
|
||||
|
||||
req.allowOverwrite = true;
|
||||
CHECK(exportBank(fx.session, req).status == ExportStatus::Written);
|
||||
CHECK(readFile(fx.destPath()) != prior);
|
||||
}
|
||||
|
||||
static void testUnknownBankAndUnsavedProjectAreNamedSeparately() {
|
||||
Fixture fx("guards");
|
||||
ExportRequest req = fx.request();
|
||||
req.bankId = "no-such-bank";
|
||||
CHECK(exportBank(fx.session, req).status == ExportStatus::NoSuchBank);
|
||||
|
||||
ExportRequest unsaved = fx.request();
|
||||
unsaved.projectDir.clear();
|
||||
CHECK(exportBank(fx.session, unsaved).status == ExportStatus::NoProjectDir);
|
||||
CHECK(!exists(fx.destPath()));
|
||||
}
|
||||
|
||||
int main() {
|
||||
testHealthyExportCarriesEveryPayloadByteExact();
|
||||
testEmittedManifestBytesCarryNoPath();
|
||||
testFailureInjectedMidWriteLeavesNoPackageAndSparesThePriorFile();
|
||||
testPayloadChangedBetweenDigestAndStreamAborts();
|
||||
testExportTouchesNoProjectState();
|
||||
testEmptyBankExportsAsAValidZeroEntryPackage();
|
||||
testIncompleteBankRefusesUntilConfirmed();
|
||||
testExistingDestinationRefusesUntilConfirmed();
|
||||
testUnknownBankAndUnsavedProjectAreNamedSeparately();
|
||||
|
||||
if (g_fail == 0) {
|
||||
std::printf("export_bank_tests: all passed\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("export_bank_tests: %d failure(s)\n", g_fail);
|
||||
return 1;
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
// Standalone tests for reasampler::package::export_plan — no REAPER, no filesystem,
|
||||
// no test framework. The planner's totality claim is the point: every input class
|
||||
// (missing / unreadable / unrepresentable / zero / one) classifies here, and the
|
||||
// names it produces are asserted against the codec's OWN predicates rather than
|
||||
// against a hand-copied rule.
|
||||
|
||||
#include "../src/core/package/export_plan.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdio>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../src/core/package/package_format.h"
|
||||
#include "../src/core/package/package_manifest.h"
|
||||
|
||||
using namespace reasampler::package;
|
||||
using namespace reasampler::model;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
// --- fixtures ----------------------------------------------------------------
|
||||
|
||||
static Sample sampleAt(const std::string& id, const std::string& relativePath) {
|
||||
Sample s;
|
||||
s.id = id;
|
||||
s.displayName = id + " display";
|
||||
s.relativePath = relativePath;
|
||||
s.sampleRate = 48000;
|
||||
s.channelCount = 2;
|
||||
s.contentHash = "0123456789abcdef";
|
||||
return s;
|
||||
}
|
||||
|
||||
static ExportCandidate present(const std::string& id, const std::string& rel) {
|
||||
return ExportCandidate{sampleAt(id, rel), SourceFileState::Present};
|
||||
}
|
||||
|
||||
static ExportCandidate withState(const std::string& id, const std::string& rel,
|
||||
SourceFileState state) {
|
||||
return ExportCandidate{sampleAt(id, rel), state};
|
||||
}
|
||||
|
||||
static ExportInputs bankOf(std::vector<ExportCandidate> candidates) {
|
||||
ExportInputs in;
|
||||
in.bankDisplayName = "Drums";
|
||||
in.candidates = std::move(candidates);
|
||||
std::vector<std::string> ids;
|
||||
for (const ExportCandidate& c : in.candidates) ids.push_back(c.sample.id);
|
||||
in.slots.resetDense(ids);
|
||||
return in;
|
||||
}
|
||||
|
||||
static bool hasExclusion(const ExportPlan& p, const std::string& id, ExclusionReason why) {
|
||||
for (const ExcludedEntry& e : p.excluded)
|
||||
if (e.sampleId == id && e.reason == why) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// --- the four classification inputs the plan names ---------------------------
|
||||
|
||||
static void testZeroSamplesIsAReadyEmptyPlan() {
|
||||
const ExportPlan p = planExport(bankOf({}));
|
||||
CHECK(p.verdict == ExportVerdict::Ready);
|
||||
CHECK(p.manifest.entries.empty());
|
||||
CHECK(p.sourceRelativePaths.empty());
|
||||
CHECK(p.excluded.empty());
|
||||
CHECK(p.manifest.bankDisplayName == "Drums");
|
||||
CHECK(p.manifest.slots.empty());
|
||||
}
|
||||
|
||||
static void testOneSamplePresentShips() {
|
||||
const ExportPlan p = planExport(bankOf({present("s1", "reasampler_bank/kick.wav")}));
|
||||
CHECK(p.verdict == ExportVerdict::Ready);
|
||||
CHECK(p.manifest.entries.size() == 1);
|
||||
CHECK(p.excluded.empty());
|
||||
CHECK(p.manifest.entries[0].fileName == "kick.wav");
|
||||
CHECK(p.manifest.entries[0].sample.id == "s1");
|
||||
// The source spelling survives only on the side channel; the transport record
|
||||
// names the payload by its bare package name.
|
||||
CHECK(p.sourceRelativePaths.size() == 1);
|
||||
CHECK(p.sourceRelativePaths[0] == "reasampler_bank/kick.wav");
|
||||
CHECK(p.manifest.entries[0].sample.relativePath == "kick.wav");
|
||||
}
|
||||
|
||||
static void testMissingFileIsIncompleteNotRefused() {
|
||||
const ExportPlan p = planExport(bankOf({
|
||||
present("s1", "reasampler_bank/kick.wav"),
|
||||
withState("s2", "reasampler_bank/gone.wav", SourceFileState::Missing),
|
||||
}));
|
||||
CHECK(p.verdict == ExportVerdict::Incomplete);
|
||||
CHECK(p.manifest.entries.size() == 1);
|
||||
CHECK(p.manifest.entries[0].sample.id == "s1");
|
||||
CHECK(p.excluded.size() == 1);
|
||||
CHECK(hasExclusion(p, "s2", ExclusionReason::FileMissing));
|
||||
CHECK(p.excluded[0].relativePath == "reasampler_bank/gone.wav");
|
||||
}
|
||||
|
||||
static void testUnreadableFileStaysDistinctFromMissing() {
|
||||
const ExportPlan p = planExport(bankOf({
|
||||
withState("s1", "reasampler_bank/locked.wav", SourceFileState::Unreadable),
|
||||
withState("s2", "reasampler_bank/gone.wav", SourceFileState::Missing),
|
||||
}));
|
||||
CHECK(p.verdict == ExportVerdict::Incomplete);
|
||||
CHECK(p.manifest.entries.empty());
|
||||
CHECK(p.excluded.size() == 2);
|
||||
CHECK(hasExclusion(p, "s1", ExclusionReason::FileUnreadable));
|
||||
CHECK(hasExclusion(p, "s2", ExclusionReason::FileMissing));
|
||||
CHECK(!hasExclusion(p, "s1", ExclusionReason::FileMissing));
|
||||
}
|
||||
|
||||
static void testUnrepresentableRecordRefusesWholeExport() {
|
||||
// A traversing nested path — the one thing an index record can carry that
|
||||
// BankModel::add does not itself refuse.
|
||||
const ExportPlan traversal =
|
||||
planExport(bankOf({present("s1", "reasampler_bank/kick.wav"),
|
||||
present("s2", "reasampler_bank/../evil.wav")}));
|
||||
CHECK(traversal.verdict == ExportVerdict::Refused);
|
||||
CHECK(hasExclusion(traversal, "s2", ExclusionReason::RecordUnrepresentable));
|
||||
CHECK(traversal.manifest.entries.size() == 1); // still reports what WOULD ship
|
||||
|
||||
const ExportPlan emptyId = planExport(bankOf({present("", "reasampler_bank/kick.wav")}));
|
||||
CHECK(emptyId.verdict == ExportVerdict::Refused);
|
||||
|
||||
const ExportPlan absolute =
|
||||
planExport(bankOf({present("s1", "C:/elsewhere/kick.wav")}));
|
||||
CHECK(absolute.verdict == ExportVerdict::Refused);
|
||||
CHECK(hasExclusion(absolute, "s1", ExclusionReason::RecordUnrepresentable));
|
||||
|
||||
// Refused outranks Incomplete: a corrupt record is not something the
|
||||
// "export the present N" confirm can proceed past.
|
||||
const ExportPlan both = planExport(bankOf({
|
||||
withState("s1", "reasampler_bank/gone.wav", SourceFileState::Missing),
|
||||
present("s2", "reasampler_bank/../evil.wav"),
|
||||
}));
|
||||
CHECK(both.verdict == ExportVerdict::Refused);
|
||||
}
|
||||
|
||||
// --- transport names ----------------------------------------------------------
|
||||
|
||||
static void testHostileNamesAreRepairedNotRelayed() {
|
||||
// Every one of these is a name the codec refuses and a filesystem somewhere
|
||||
// produces honestly.
|
||||
const std::vector<std::string> hostile = {
|
||||
"reasampler_bank/ki:ck?.wav", "reasampler_bank/a|b<c>d\"e*f.wav",
|
||||
"reasampler_bank/CON.wav", "reasampler_bank/nul",
|
||||
"reasampler_bank/trailing .wav ", "reasampler_bank/dots...",
|
||||
// A literal ".." COMPONENT is not a name to repair — it is a traversing
|
||||
// record, and the Refused test above owns it.
|
||||
"reasampler_bank/.....", "reasampler_bank/.",
|
||||
std::string("reasampler_bank/bad\xC3.wav"), // truncated UTF-8 sequence
|
||||
std::string("reasampler_bank/") + std::string(400, 'x') + ".wav",
|
||||
};
|
||||
std::vector<ExportCandidate> candidates;
|
||||
for (std::size_t i = 0; i < hostile.size(); ++i)
|
||||
candidates.push_back(present("s" + std::to_string(i), hostile[i]));
|
||||
|
||||
const ExportPlan p = planExport(bankOf(candidates));
|
||||
CHECK(p.verdict == ExportVerdict::Ready);
|
||||
CHECK(p.manifest.entries.size() == hostile.size());
|
||||
for (const PackageEntry& e : p.manifest.entries) {
|
||||
CHECK(isValidEntryName(e.fileName));
|
||||
CHECK(isValidNestedSamplePath(e.sample.relativePath));
|
||||
}
|
||||
}
|
||||
|
||||
static void testCaseFoldedCollisionsAreDisambiguated() {
|
||||
const ExportPlan p = planExport(bankOf({
|
||||
present("s1", "reasampler_bank/Kick.wav"),
|
||||
present("s2", "reasampler_bank/kick.wav"),
|
||||
present("s3", "reasampler_bank/KICK.wav"),
|
||||
}));
|
||||
CHECK(p.verdict == ExportVerdict::Ready);
|
||||
CHECK(p.manifest.entries.size() == 3);
|
||||
for (std::size_t i = 0; i < p.manifest.entries.size(); ++i)
|
||||
for (std::size_t j = i + 1; j < p.manifest.entries.size(); ++j)
|
||||
CHECK(!sameEntryName(p.manifest.entries[i].fileName,
|
||||
p.manifest.entries[j].fileName));
|
||||
CHECK(p.manifest.entries[0].fileName == "Kick.wav");
|
||||
// The suffix goes before the extension, so the payload keeps its type.
|
||||
CHECK(p.manifest.entries[1].fileName == "kick_2.wav");
|
||||
}
|
||||
|
||||
static void testSanitizeNeverReturnsANameTheCodecRefuses() {
|
||||
const std::vector<std::string> raws = {
|
||||
"", ".", "..", "...", " ", "com1", "LPT9.WAV", "a/b", "a\\b", "C:evil",
|
||||
std::string("\x01\x02\x03"), std::string(300, 'y'),
|
||||
std::string("caf\xC3\xA9.wav"), // well-formed UTF-8 must survive intact
|
||||
};
|
||||
for (const std::string& raw : raws) CHECK(isValidEntryName(sanitizeEntryName(raw)));
|
||||
CHECK(sanitizeEntryName("caf\xC3\xA9.wav") == "caf\xC3\xA9.wav");
|
||||
CHECK(sanitizeEntryName("kick.wav") == "kick.wav");
|
||||
}
|
||||
|
||||
// --- what the plan hands the codec -------------------------------------------
|
||||
|
||||
static void testPlannedManifestSatisfiesTheCodec() {
|
||||
ExportPlan p = planExport(bankOf({
|
||||
present("s1", "reasampler_bank/Kick.wav"),
|
||||
present("s2", "reasampler_bank/kick.wav"),
|
||||
present("s3", "reasampler_bank/CON.wav"),
|
||||
present("s4", std::string("reasampler_bank/caf\xC3\xA9 mix.wav")),
|
||||
}));
|
||||
// byteLength/byteHash are the shell's to measure; stand them in so the encode
|
||||
// path under test is the naming, not the digest.
|
||||
for (PackageEntry& e : p.manifest.entries) {
|
||||
e.byteLength = 44;
|
||||
e.byteHash = "aaaaaaaabbbbbbbb";
|
||||
}
|
||||
const std::optional<std::string> json = serializeManifest(p.manifest);
|
||||
CHECK(json.has_value());
|
||||
if (json) {
|
||||
const std::optional<PackageManifest> back = deserializeManifest(*json);
|
||||
CHECK(back.has_value());
|
||||
if (back) CHECK(*back == p.manifest);
|
||||
}
|
||||
}
|
||||
|
||||
static void testSlotsFollowMembership() {
|
||||
ExportInputs in = bankOf({
|
||||
present("s1", "reasampler_bank/a.wav"),
|
||||
withState("s2", "reasampler_bank/gone.wav", SourceFileState::Missing),
|
||||
present("s3", "reasampler_bank/c.wav"),
|
||||
});
|
||||
const ExportPlan p = planExport(in);
|
||||
CHECK(p.manifest.slots.slotOf("s2") == -1); // an excluded id keeps no display position
|
||||
CHECK(p.manifest.slots.slotOf("s1") >= 0);
|
||||
CHECK(p.manifest.slots.slotOf("s3") >= 0);
|
||||
CHECK(p.manifest.slots.size() == 2);
|
||||
}
|
||||
|
||||
static void testPlanIsDeterministic() {
|
||||
const ExportInputs in = bankOf({
|
||||
present("s1", "reasampler_bank/Kick.wav"),
|
||||
present("s2", "reasampler_bank/kick.wav"),
|
||||
withState("s3", "reasampler_bank/gone.wav", SourceFileState::Missing),
|
||||
});
|
||||
const ExportPlan a = planExport(in);
|
||||
const ExportPlan b = planExport(in);
|
||||
CHECK(a.verdict == b.verdict);
|
||||
CHECK(a.manifest == b.manifest);
|
||||
CHECK(a.sourceRelativePaths == b.sourceRelativePaths);
|
||||
CHECK(a.excluded.size() == b.excluded.size());
|
||||
}
|
||||
|
||||
int main() {
|
||||
testZeroSamplesIsAReadyEmptyPlan();
|
||||
testOneSamplePresentShips();
|
||||
testMissingFileIsIncompleteNotRefused();
|
||||
testUnreadableFileStaysDistinctFromMissing();
|
||||
testUnrepresentableRecordRefusesWholeExport();
|
||||
testHostileNamesAreRepairedNotRelayed();
|
||||
testCaseFoldedCollisionsAreDisambiguated();
|
||||
testSanitizeNeverReturnsANameTheCodecRefuses();
|
||||
testPlannedManifestSatisfiesTheCodec();
|
||||
testSlotsFollowMembership();
|
||||
testPlanIsDeterministic();
|
||||
|
||||
if (g_fail == 0) {
|
||||
std::printf("export_plan_tests: all passed\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("export_plan_tests: %d failure(s)\n", g_fail);
|
||||
return 1;
|
||||
}
|
||||
Reference in New Issue
Block a user