081b6f1028
Pure planner classifies missing/unreadable/unrepresentable and repairs transport names; the verb digests, streams and commits atomically over a const session.
269 lines
11 KiB
C++
269 lines
11 KiB
C++
// 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;
|
|
}
|