1aebf51938
An additively-tagged newer package that fails to parse now reports TooNew (with writer semver) instead of unactionable Malformed. Format layer also refuses encoding a zero-length entry, honoring the shell's appendPayload contract; both test-covered.
277 lines
11 KiB
C++
277 lines
11 KiB
C++
// Standalone tests for reasampler::package's manifest codec — no REAPER, no
|
|
// test framework. The round-trip fixture exercises every manifest field and
|
|
// every Sample optional in both present and absent states; the rejection suite
|
|
// pins the entry-name rule on encode AND decode.
|
|
|
|
#include "../src/core/package/package_manifest.h"
|
|
|
|
#include <cstdio>
|
|
#include <optional>
|
|
#include <string>
|
|
|
|
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 ----------------------------------------------------------------
|
|
|
|
// Every Sample field populated, every optional PRESENT.
|
|
static Sample fullSample() {
|
|
Sample s;
|
|
s.id = "smp-full";
|
|
s.displayName = "Kick (wet)";
|
|
s.relativePath = "reasampler_bank/kick.wav";
|
|
s.sourceMode = SourceMode::RazorArea;
|
|
s.sourceRange = {1.25, 3.5, 480.0, 1920.0};
|
|
s.trackGuids = {"{AAA}", "{BBB}"};
|
|
s.wetDry = 0.75;
|
|
s.channelCount = 2;
|
|
s.sampleRate = 48000;
|
|
s.lengthSeconds = 2.25;
|
|
s.lengthBeats = 4.5;
|
|
s.captureTempo = 120.5;
|
|
s.captureTimeSigNum = 7;
|
|
s.captureTimeSigDenom = 8;
|
|
s.key = "F#m";
|
|
s.rootNote = 60;
|
|
s.loop = LoopPoints{100, 4800};
|
|
s.levels = {-0.3, -12.7, -14.0};
|
|
s.clipped = true;
|
|
s.tier = Tier::Archive;
|
|
s.contentHash = "W0123456789abcdef";
|
|
s.provenance = Provenance{"smp-parent", "fx-snapshot"};
|
|
s.createdTimestamp = 1754000000;
|
|
return s;
|
|
}
|
|
|
|
// Every Sample optional ABSENT (key, rootNote, loop, provenance).
|
|
static Sample bareSample() {
|
|
Sample s;
|
|
s.id = "smp-bare";
|
|
s.displayName = "Snare";
|
|
s.relativePath = "reasampler_bank/snare.wav";
|
|
s.sourceMode = SourceMode::Realtime;
|
|
s.contentHash = "Wfedcba9876543210";
|
|
s.createdTimestamp = 1754000001;
|
|
return s;
|
|
}
|
|
|
|
static PackageManifest fixture() {
|
|
PackageManifest m;
|
|
m.bankDisplayName = "Drums \"live\""; // escaping exercised
|
|
m.exportTimestamp = 1754100000;
|
|
m.entries.push_back({"kick.wav", 96000, "1111222233334444", fullSample()});
|
|
m.entries.push_back({"snare.wav", 48000, "5555666677778888", bareSample()});
|
|
m.slots.append("smp-full");
|
|
m.slots.append("smp-bare");
|
|
m.slots.remove("smp-full"); // leaves a gap: slots round-trip must keep it
|
|
return m;
|
|
}
|
|
|
|
// --- round trip --------------------------------------------------------------
|
|
|
|
static void testRoundTripEveryField() {
|
|
const PackageManifest m = fixture();
|
|
auto json = serializeManifest(m);
|
|
CHECK(json.has_value());
|
|
auto back = deserializeManifest(*json);
|
|
CHECK(back.has_value());
|
|
CHECK(*back == m);
|
|
// Spot-check both optional states survived (== above proves it; these name
|
|
// the claim so a failure reads directly).
|
|
CHECK(back->entries[0].sample.loop.has_value());
|
|
CHECK(back->entries[0].sample.provenance.has_value());
|
|
CHECK(!back->entries[1].sample.key.has_value());
|
|
CHECK(!back->entries[1].sample.rootNote.has_value());
|
|
CHECK(back->slots.idAt(0).empty()); // the slot gap survived
|
|
CHECK(back->slots.slotOf("smp-bare") == 1);
|
|
}
|
|
|
|
static void testEmptyManifestRoundTrips() {
|
|
PackageManifest m;
|
|
auto json = serializeManifest(m);
|
|
CHECK(json.has_value());
|
|
auto back = deserializeManifest(*json);
|
|
CHECK(back.has_value());
|
|
CHECK(*back == m);
|
|
}
|
|
|
|
// --- forward compatibility ---------------------------------------------------
|
|
|
|
static void testUnknownKeysSkippedAtEveryLevel() {
|
|
// A future additive manifest: unknown keys at the root, inside an entry,
|
|
// and inside the nested index blob itself.
|
|
const std::string json =
|
|
"{\"bankName\":\"B\",\"exported\":7,"
|
|
"\"instrumentState\":{\"nested\":[1,2,{\"x\":\"y\"}]},"
|
|
"\"entries\":[{\"name\":\"a.wav\",\"length\":10,\"hash\":\"h\","
|
|
"\"futureField\":\"ignored\","
|
|
"\"index\":{\"version\":1,\"futureIndexField\":42,\"samples\":[{\"id\":\"s1\","
|
|
"\"relativePath\":\"bank/a.wav\"}]}}],"
|
|
"\"slots\":[],\"trailingUnknown\":null}";
|
|
auto m = deserializeManifest(json);
|
|
CHECK(m.has_value());
|
|
CHECK(m->bankDisplayName == "B");
|
|
CHECK(m->exportTimestamp == 7);
|
|
CHECK(m->entries.size() == 1);
|
|
CHECK(m->entries[0].fileName == "a.wav");
|
|
CHECK(m->entries[0].byteLength == 10);
|
|
CHECK(m->entries[0].sample.id == "s1");
|
|
}
|
|
|
|
// --- rejection: entry names, both directions ---------------------------------
|
|
|
|
static void testEncodeRejectsBadEntryName() {
|
|
for (const char* bad : {"..\\evil.wav", "../evil.wav", "dir/a.wav", "C:\\a.wav", "", ".."}) {
|
|
PackageManifest m = fixture();
|
|
m.entries[0].fileName = bad;
|
|
CHECK(!serializeManifest(m).has_value());
|
|
}
|
|
}
|
|
|
|
static void testDecodeRejectsBadEntryName() {
|
|
for (const char* bad : {"..\\\\evil.wav", "../evil.wav", "dir/a.wav", "C:\\\\a.wav", ".."}) {
|
|
// Hand-rolled JSON: a hostile package is not limited to what encode emits.
|
|
std::string json =
|
|
std::string("{\"entries\":[{\"name\":\"") + bad +
|
|
"\",\"length\":1,\"hash\":\"h\","
|
|
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\","
|
|
"\"relativePath\":\"bank/a.wav\"}]}}]}";
|
|
CHECK(!deserializeManifest(json).has_value());
|
|
}
|
|
}
|
|
|
|
static void testDuplicateEntryNamesRejectedBothWays() {
|
|
PackageManifest m = fixture();
|
|
m.entries[1].fileName = m.entries[0].fileName;
|
|
CHECK(!serializeManifest(m).has_value());
|
|
|
|
// Decode side, from a hand-built duplicate (a hostile package is not
|
|
// limited to what encode emits).
|
|
const std::string dup =
|
|
"{\"entries\":["
|
|
"{\"name\":\"a.wav\",\"length\":1,\"hash\":\"h\","
|
|
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\",\"relativePath\":\"p\"}]}},"
|
|
"{\"name\":\"a.wav\",\"length\":2,\"hash\":\"i\","
|
|
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s2\",\"relativePath\":\"q\"}]}}]}";
|
|
CHECK(!deserializeManifest(dup).has_value());
|
|
}
|
|
|
|
static void testDuplicateEntriesKeyRejected() {
|
|
// A repeated "entries" key must not accumulate into two arrays' worth of
|
|
// entries — reject rather than silently union them.
|
|
const std::string json =
|
|
"{\"entries\":[{\"name\":\"a.wav\",\"length\":1,\"hash\":\"h\","
|
|
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\",\"relativePath\":\"p\"}]}}],"
|
|
"\"entries\":[{\"name\":\"b.wav\",\"length\":1,\"hash\":\"h\","
|
|
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s2\",\"relativePath\":\"q\"}]}}]}";
|
|
CHECK(!deserializeManifest(json).has_value());
|
|
}
|
|
|
|
// --- rejection: structural ---------------------------------------------------
|
|
|
|
// A zero-length entry cannot round-trip through the shell's filesystem seam
|
|
// (src/shell/package's appendPayload refuses an empty payload) — the format
|
|
// layer must never produce one, so encode refuses it. Decode does not enforce
|
|
// this (a hostile/older package declaring one is not this codec's concern).
|
|
static void testEncodeRejectsZeroLengthEntry() {
|
|
PackageManifest m = fixture();
|
|
m.entries[0].byteLength = 0;
|
|
CHECK(!serializeManifest(m).has_value());
|
|
}
|
|
|
|
static void testEncodeRejectsUnrepresentableSample() {
|
|
PackageManifest m = fixture();
|
|
m.entries[0].sample.id.clear(); // BankModel::add rejects an empty id
|
|
CHECK(!serializeManifest(m).has_value());
|
|
|
|
PackageManifest m2 = fixture();
|
|
m2.entries[0].sample.relativePath = "C:/abs/kick.wav"; // and an absolute path
|
|
CHECK(!serializeManifest(m2).has_value());
|
|
}
|
|
|
|
static void testDecodeRejectsMalformedShapes() {
|
|
CHECK(!deserializeManifest("").has_value());
|
|
CHECK(!deserializeManifest("not json").has_value());
|
|
CHECK(!deserializeManifest("[]").has_value());
|
|
CHECK(!deserializeManifest("{\"entries\":[{}]}").has_value()); // entry missing fields
|
|
// Missing one required entry field apiece.
|
|
CHECK(!deserializeManifest(
|
|
"{\"entries\":[{\"length\":1,\"hash\":\"h\","
|
|
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s\",\"relativePath\":\"p\"}]}}]}")
|
|
.has_value());
|
|
CHECK(!deserializeManifest(
|
|
"{\"entries\":[{\"name\":\"a.wav\",\"hash\":\"h\","
|
|
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s\",\"relativePath\":\"p\"}]}}]}")
|
|
.has_value());
|
|
CHECK(!deserializeManifest(
|
|
"{\"entries\":[{\"name\":\"a.wav\",\"length\":1,"
|
|
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s\",\"relativePath\":\"p\"}]}}]}")
|
|
.has_value());
|
|
CHECK(!deserializeManifest(
|
|
"{\"entries\":[{\"name\":\"a.wav\",\"length\":1,\"hash\":\"h\"}]}").has_value());
|
|
// Negative length.
|
|
CHECK(!deserializeManifest(
|
|
"{\"entries\":[{\"name\":\"a.wav\",\"length\":-1,\"hash\":\"h\","
|
|
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s\",\"relativePath\":\"p\"}]}}]}")
|
|
.has_value());
|
|
// A nested index that is not exactly one sample (zero and two).
|
|
CHECK(!deserializeManifest(
|
|
"{\"entries\":[{\"name\":\"a.wav\",\"length\":1,\"hash\":\"h\","
|
|
"\"index\":{\"version\":1,\"samples\":[]}}]}").has_value());
|
|
CHECK(!deserializeManifest(
|
|
"{\"entries\":[{\"name\":\"a.wav\",\"length\":1,\"hash\":\"h\","
|
|
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\",\"relativePath\":\"p\"},"
|
|
"{\"id\":\"s2\",\"relativePath\":\"q\"}]}}]}").has_value());
|
|
// A nested sample BankModel::add drops (absolute path) fails the entry —
|
|
// the silent drop must not half-parse into an empty index.
|
|
CHECK(!deserializeManifest(
|
|
"{\"entries\":[{\"name\":\"a.wav\",\"length\":1,\"hash\":\"h\","
|
|
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s\","
|
|
"\"relativePath\":\"C:/abs.wav\"}]}}]}").has_value());
|
|
// An out-of-range enum inside the nested Sample blob fails the entry: the
|
|
// manifest defines no enum of its own, and bank_model's codec REJECTS an
|
|
// unknown sourceMode/tier rather than degrading — so growing one of those
|
|
// vocabularies is a minReaderVersion bump, not an additive change (see this
|
|
// directory's CLAUDE.md).
|
|
CHECK(!deserializeManifest(
|
|
"{\"entries\":[{\"name\":\"a.wav\",\"length\":1,\"hash\":\"h\","
|
|
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s\",\"relativePath\":\"p\","
|
|
"\"sourceMode\":99}]}}]}").has_value());
|
|
// Trailing garbage after the root object.
|
|
auto json = serializeManifest(fixture());
|
|
CHECK(json.has_value());
|
|
CHECK(!deserializeManifest(*json + "x").has_value());
|
|
// Trailing garbage after the EMPTY-object shortcut specifically: this path
|
|
// returned early before reaching the eof check, so "{}JUNK" parsed valid.
|
|
CHECK(!deserializeManifest("{}JUNK").has_value());
|
|
CHECK(deserializeManifest("{}").has_value());
|
|
// Truncation at a few JSON-level offsets (byte-level truncation of the whole
|
|
// package is bank_package's suite).
|
|
CHECK(!deserializeManifest(json->substr(0, json->size() / 2)).has_value());
|
|
CHECK(!deserializeManifest(json->substr(0, 1)).has_value());
|
|
}
|
|
|
|
int main() {
|
|
testRoundTripEveryField();
|
|
testEmptyManifestRoundTrips();
|
|
testUnknownKeysSkippedAtEveryLevel();
|
|
testEncodeRejectsBadEntryName();
|
|
testDecodeRejectsBadEntryName();
|
|
testDuplicateEntryNamesRejectedBothWays();
|
|
testDuplicateEntriesKeyRejected();
|
|
testEncodeRejectsZeroLengthEntry();
|
|
testEncodeRejectsUnrepresentableSample();
|
|
testDecodeRejectsMalformedShapes();
|
|
|
|
if (g_fail == 0) {
|
|
std::printf("package_manifest_tests: all passed\n");
|
|
return 0;
|
|
}
|
|
std::printf("package_manifest_tests: %d failure(s)\n", g_fail);
|
|
return 1;
|
|
}
|