Merge dev into phase-g: Phase Ε/Ρ and the 1.5.0 bump meet Phase Gamma's instrument work; 120/120 green
The per-directory CLAUDE.md count is re-derived at twenty-seven rather than carried from either side. The "Decouple the instrument reload from VST3 activation" TODO entry does not survive: Γ-W3-T1 landed it, and COMPLETED.md carries the discharge.
This commit is contained in:
+118
@@ -0,0 +1,118 @@
|
||||
# The frozen `.rsbank` compatibility corpus
|
||||
|
||||
Real RSBK bytes, committed. `tests/test_package_compat.cpp` decodes them;
|
||||
`tests/test_package_round_trip.cpp` drives them through the import and export verbs.
|
||||
|
||||
## THE RULE: this corpus is append-only
|
||||
|
||||
**No file here is ever regenerated or edited.** When a future format version ships, add
|
||||
its fixture beside these and leave every existing one alone.
|
||||
|
||||
The reason is the whole point of the corpus. These bytes exist to catch a format change
|
||||
that quietly breaks a compatibility direction. A fixture regenerated by the build that
|
||||
broke it agrees with that build by construction and catches nothing — which is exactly
|
||||
the failure mode a version ladder exists to prevent. The same argument forbids a test
|
||||
that builds its own fixture at run time. The repo-root `.gitattributes` (`*.rsbank
|
||||
binary`) keeps this mechanical: without it, git's NUL-sniffing heuristic could
|
||||
text-classify a future short/ASCII fixture and CRLF-mangle a line ending on a Windows
|
||||
checkout, silently breaking the frozen-bytes premise.
|
||||
|
||||
A fixture's BYTES are frozen forever; a fixture's ASSERTION is not. `additive_forward.rsbank`
|
||||
and `refuse_structural.rsbank` carry version pairs one step past THIS build's ladder (2/1
|
||||
and 2/2). When a future build's own `kPackageFormatVersion` reaches 2, `refuse_structural.rsbank`
|
||||
classifies `Readable` under the new ladder — its bytes never claimed to need more than
|
||||
format 2 — so that build re-aims the assertion (and adds a new synthetic pair one step
|
||||
past the NEW ladder); it never re-cuts the fixture. If a truncation or hostile-name
|
||||
fixture ever changes classification, that is a regression, never a ladder consequence.
|
||||
|
||||
## Provenance
|
||||
|
||||
`v1_shipping.rsbank` was produced by running this repo's own export verb (`exportBank`)
|
||||
at version **1.4.0** over a one-sample bank, and copying the emitted file here verbatim.
|
||||
Every other fixture is derived from those bytes: eight of the nine truncations are
|
||||
prefixes of `v1_shipping.rsbank` (the ninth, `trunc_additive_forward.rsbank`, is a prefix
|
||||
of `additive_forward.rsbank` itself — a prefix of a prefix, still frozen bytes, never
|
||||
regenerated), and the synthetic packages reuse their manifest region under different
|
||||
version integers or a hand-written hostile manifest (the encoder refuses to write one,
|
||||
which is why those could not come from the verb).
|
||||
|
||||
Payloads are one 300-byte 16-bit mono WAV. The properties under test are structural —
|
||||
version integers, framing arithmetic, name validation — so a larger payload proves
|
||||
nothing extra and costs the repo bytes forever. Whole corpus: ~14 KB.
|
||||
|
||||
Adding a fixture for a future version means writing it with **that** version's shipping
|
||||
build, exactly as this one was, and recording the build's version here.
|
||||
|
||||
## What each fixture proves
|
||||
|
||||
### The three version fixtures
|
||||
|
||||
| File | `formatVersion` / `minReaderVersion` | Verdict | Proves |
|
||||
|---|---|---|---|
|
||||
| `v1_shipping.rsbank` | 1 / 1 | `Readable` | This build reads what it wrote: header, one manifest entry, the entry digest, and every `Sample` field with every optional present. Writer semver `1.4.0` is asserted **literally**, not against `stampVersion()` — comparing against the running build would let a version bump re-anchor the fixture silently. |
|
||||
| `additive_forward.rsbank` | 2 / 1 | `Readable` | An additive newer writer still reads. Carries three keys this build has never heard of — `exportTool` at the manifest root, `futureEntryKey` on the entry, `futureSampleKey` inside the nested `Sample` blob — and decodes to *exactly* the manifest `v1_shipping.rsbank` decodes to. Writer semver `1.9.0`. |
|
||||
| `refuse_structural.rsbank` | 2 / 2 | `TooNew` | A structural newer writer is refused whole. The header through the writer semver still reads, so the refusal can name all three facts (`1.9.0`, needs format 2, this build reads 1); no manifest, no layout, no partial success. Its body is `v1_shipping.rsbank`'s own manifest, which parses — so the refusal is a **decision**, not an inability. |
|
||||
|
||||
### Truncation — one file per distinct decode failure site
|
||||
|
||||
The first eight are prefixes of `v1_shipping.rsbank` (907-byte prefix + 300-byte payload
|
||||
= 1207 bytes), so `formatVersion` never exceeds this build's on that path. All classify
|
||||
`Malformed`; none may classify `TooNew`, since "install a newer build" does not fix a
|
||||
partial download.
|
||||
|
||||
| File | Bytes | Site the cut lands in |
|
||||
|---|---|---|
|
||||
| `trunc_magic.rsbank` | 2 | Inside the 4-byte magic. |
|
||||
| `trunc_version_pair.rsbank` | 10 | Inside the frozen header's `minReaderVersion` u32. |
|
||||
| `trunc_writer_semver.rsbank` | 18 | Inside the frozen header's writer semver. |
|
||||
| `trunc_manifest_length.rsbank` | 23 | Inside the manifest-length u32. |
|
||||
| `trunc_manifest_body.rsbank` | 466 | Inside the manifest JSON. |
|
||||
| `trunc_payload_start.rsbank` | 907 | At the payload boundary. RSBK stores no layout section — the layout is derived from the manifest's entries — so this is the cut that exercises "manifest parses, layout computes, exact-size proof fails". |
|
||||
| `trunc_payload_middle.rsbank` | 1057 | Inside the first payload. |
|
||||
| `trunc_one_short.rsbank` | 1206 | One byte short of the total. |
|
||||
|
||||
`trunc_additive_forward.rsbank` is the ninth: a 983-byte prefix of `additive_forward.rsbank`
|
||||
(25-byte frozen header/manifest-length region + 958-byte manifest = 983), cut exactly at
|
||||
ITS payload boundary. `formatVersion` here is 2 — one past this build's — so this is the
|
||||
one truncation that proves the exact-size-proof failure stays `Malformed` even when
|
||||
`formatVersion > kPackageFormatVersion`, rather than relabeling to `TooNew` (the parse
|
||||
branch is the only one that relabels — see `src/core/package/CLAUDE.md`).
|
||||
|
||||
### Hostile names — refused at decode, before any planner
|
||||
|
||||
The two naming fields carry different rules (`src/core/package/CLAUDE.md`), so each
|
||||
fixture keeps the other field spelled cleanly (`kick.wav`) and the refusal is
|
||||
attributable to the field under test.
|
||||
|
||||
Entry name — a bare file name, no path expression possible (`isValidEntryName`):
|
||||
|
||||
| File | Entry name |
|
||||
|---|---|
|
||||
| `hostile_name_dotdot.rsbank` | `..` |
|
||||
| `hostile_name_parent_slash.rsbank` | `../evil.wav` |
|
||||
| `hostile_name_parent_backslash.rsbank` | `..\evil.wav` |
|
||||
| `hostile_name_subdir_slash.rsbank` | `sub/evil.wav` |
|
||||
| `hostile_name_drive_absolute.rsbank` | `C:\Windows\evil.wav` |
|
||||
| `hostile_name_unc_absolute.rsbank` | `\\srv\share\evil.wav` |
|
||||
|
||||
Nested `Sample::relativePath` — a path by design, refused only for traversal and
|
||||
absolute forms (`isValidNestedSamplePath`):
|
||||
|
||||
| File | `relativePath` | Guard that fires first inside the codec |
|
||||
|---|---|---|
|
||||
| `hostile_path_dotdot_slash.rsbank` | `bank/../../evil.wav` | `isValidNestedSamplePath` |
|
||||
| `hostile_path_dotdot_backslash.rsbank` | `bank\..\evil.wav` | `isValidNestedSamplePath` |
|
||||
| `hostile_path_rooted.rsbank` | `/etc/evil.wav` | `BankModel::add`'s absolute-path rejection, which drops the record and leaves the nested blob holding zero samples |
|
||||
| `hostile_path_drive_absolute.rsbank` | `C:\Windows\evil.wav` | as above |
|
||||
| `hostile_path_unc_absolute.rsbank` | `\\srv\share\evil.wav` | as above |
|
||||
|
||||
Both guards are inside the codec and both refuse the whole package, so the security
|
||||
property is the same either way; the split is recorded because a change to either guard
|
||||
alone would still leave these fixtures passing.
|
||||
|
||||
### The round-trip anchor
|
||||
|
||||
`v1_shipping.rsbank` doubles as it: the file **is** a real export, so importing it and
|
||||
exporting the resulting bank closes export → import → export over frozen bytes. Entry
|
||||
names may legally change across the trip (the importer re-spells a bank file, the
|
||||
exporter mints its own transport name); the payload bytes may not.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
RS
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,25 @@
|
||||
#pragma once
|
||||
// The one accessor over the frozen package-compat corpus (tests/fixtures/package_compat).
|
||||
// REASAMPLER_PACKAGE_FIXTURE_DIR is a compile-time absolute path defined by each
|
||||
// consuming test target: the corpus is source-tree data, and a test's working directory
|
||||
// under ctest differs between single- and multi-config generators, so no relative
|
||||
// spelling reaches it from both.
|
||||
//
|
||||
// Every caller must check the returned size: a fixture that failed to open reads as an
|
||||
// empty buffer, which a "this must be Malformed" assertion would otherwise pass.
|
||||
|
||||
#include <cstdint>
|
||||
#include <fstream>
|
||||
#include <iterator>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
inline std::string packageFixturePath(const std::string& name) {
|
||||
return std::string(REASAMPLER_PACKAGE_FIXTURE_DIR) + "/" + name;
|
||||
}
|
||||
|
||||
inline std::vector<std::uint8_t> packageFixtureBytes(const std::string& name) {
|
||||
std::ifstream f(packageFixturePath(name), std::ios::binary);
|
||||
return std::vector<std::uint8_t>(std::istreambuf_iterator<char>(f),
|
||||
std::istreambuf_iterator<char>());
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
// Standalone tests for reasampler::package::bank_package — no REAPER, no test
|
||||
// framework. Byte-level suites hand-roll RSBK images with wire::putLE rather
|
||||
// than calling encodePackage, so a layout regression in encode cannot hide from
|
||||
// decode (the two sides are pinned against each other AND against raw bytes).
|
||||
|
||||
#include "../src/core/package/bank_package.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../src/core/version/app_version.h"
|
||||
#include "../src/core/wire/bytes.h"
|
||||
|
||||
using namespace reasampler::package;
|
||||
using namespace reasampler::model;
|
||||
namespace wire = reasampler::wire;
|
||||
namespace version = reasampler::version;
|
||||
|
||||
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(std::uint64_t len0, std::uint64_t len1) {
|
||||
PackageManifest m;
|
||||
m.bankDisplayName = "Drums \"live\"";
|
||||
m.exportTimestamp = 1754100000;
|
||||
m.entries.push_back({"kick.wav", len0, "1111222233334444", fullSample()});
|
||||
m.entries.push_back({"snare.wav", len1, "5555666677778888", bareSample()});
|
||||
m.slots.append("smp-full");
|
||||
m.slots.append("smp-bare");
|
||||
return m;
|
||||
}
|
||||
|
||||
// A hand-rolled RSBK image: frozen region + a raw tail (manifest framing or
|
||||
// deliberate garbage), independent of encodePackage.
|
||||
static std::vector<std::uint8_t> rawHeader(std::uint32_t fv, std::uint32_t mv,
|
||||
const std::string& semver) {
|
||||
std::vector<std::uint8_t> out;
|
||||
out.insert(out.end(), kPackageMagic, kPackageMagic + 4);
|
||||
wire::putLE(out, fv);
|
||||
wire::putLE(out, mv);
|
||||
wire::putLE(out, static_cast<std::uint32_t>(semver.size()));
|
||||
out.insert(out.end(), semver.begin(), semver.end());
|
||||
return out;
|
||||
}
|
||||
|
||||
static void appendManifest(std::vector<std::uint8_t>& out, const std::string& json) {
|
||||
wire::putLE(out, static_cast<std::uint32_t>(json.size()));
|
||||
out.insert(out.end(), json.begin(), json.end());
|
||||
}
|
||||
|
||||
// One-entry manifest JSON with `extra` spliced in as additional root content
|
||||
// ("" for none) and `sampleExtra` spliced into the nested Sample object — for
|
||||
// images a current writer would never emit.
|
||||
static std::string handManifest(const std::string& name, int length,
|
||||
const std::string& extra,
|
||||
const std::string& sampleExtra = "") {
|
||||
return std::string("{") + extra +
|
||||
"\"entries\":[{\"name\":\"" + name +
|
||||
"\",\"length\":" + std::to_string(length) + ",\"hash\":\"h\","
|
||||
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\"," + sampleExtra +
|
||||
"\"relativePath\":\"bank/a.wav\"}]}}]}";
|
||||
}
|
||||
|
||||
// --- encode / decode round trip ----------------------------------------------
|
||||
|
||||
static void testEncodeDecodeRoundTrip() {
|
||||
const PackageManifest m = fixture(96000, 48000);
|
||||
auto enc = encodePackage(m);
|
||||
CHECK(enc.has_value());
|
||||
|
||||
// Layout arithmetic: payloads start at the prefix end, in manifest order.
|
||||
CHECK(enc->layout.size() == 2);
|
||||
CHECK(enc->layout[0].name == "kick.wav");
|
||||
CHECK(enc->layout[0].offset == enc->prefix.size());
|
||||
CHECK(enc->layout[0].length == 96000);
|
||||
CHECK(enc->layout[1].offset == enc->prefix.size() + 96000);
|
||||
CHECK(enc->layout[1].length == 48000);
|
||||
CHECK(enc->totalSize == enc->prefix.size() + 96000 + 48000);
|
||||
|
||||
// decodePackage(encodePackage(x)) == x — payloads are never read by the
|
||||
// codec, so the prefix plus the true total size is the whole input.
|
||||
const DecodedPackage dec = decodePackage(enc->prefix, enc->totalSize);
|
||||
CHECK(dec.status == PackageReadability::Readable);
|
||||
CHECK(dec.manifest == m);
|
||||
CHECK(dec.layout == enc->layout);
|
||||
CHECK(dec.prefixSize == enc->prefix.size());
|
||||
|
||||
// The header stamps this build's ladder pair and its informational semver.
|
||||
CHECK(dec.header.formatVersion == kPackageFormatVersion);
|
||||
CHECK(dec.header.minReaderVersion == kPackageMinReaderVersion);
|
||||
CHECK(dec.header.writerVersion == version::stampVersion());
|
||||
}
|
||||
|
||||
static void testEncodeRefusesWhatManifestRefuses() {
|
||||
PackageManifest m = fixture(1, 1);
|
||||
m.entries[0].fileName = "../evil.wav";
|
||||
CHECK(!encodePackage(m).has_value());
|
||||
|
||||
// See src/core/package/CLAUDE.md for the shell seam that forces this.
|
||||
PackageManifest zeroLen = fixture(1, 1);
|
||||
zeroLen.entries[1].byteLength = 0;
|
||||
CHECK(!encodePackage(zeroLen).has_value());
|
||||
}
|
||||
|
||||
// --- truncation: every byte offset -------------------------------------------
|
||||
|
||||
static void testTruncationAtEveryByteOffsetIsMalformed() {
|
||||
const PackageManifest m = fixture(3, 5);
|
||||
auto enc = encodePackage(m);
|
||||
CHECK(enc.has_value());
|
||||
|
||||
// The complete on-disk image: prefix + both payloads.
|
||||
std::vector<std::uint8_t> file = enc->prefix;
|
||||
for (std::uint8_t b : {1, 2, 3, 10, 20, 30, 40, 50}) file.push_back(b);
|
||||
CHECK(file.size() == enc->totalSize);
|
||||
CHECK(decodePackage(file, file.size()).status == PackageReadability::Readable);
|
||||
|
||||
for (std::size_t cut = 0; cut < file.size(); ++cut) {
|
||||
const std::vector<std::uint8_t> truncated(file.begin(), file.begin() + cut);
|
||||
const DecodedPackage dec = decodePackage(truncated, truncated.size());
|
||||
if (dec.status != PackageReadability::Malformed) {
|
||||
std::printf("FAIL: truncation at %zu not Malformed\n", cut);
|
||||
++g_fail;
|
||||
break;
|
||||
}
|
||||
// A refused decode must not half-succeed at any cut either.
|
||||
CHECK(dec.manifest.entries.empty());
|
||||
CHECK(dec.layout.empty());
|
||||
}
|
||||
|
||||
// One byte extra (trailing garbage) is as Malformed as one byte missing.
|
||||
std::vector<std::uint8_t> extended = file;
|
||||
extended.push_back(0);
|
||||
CHECK(decodePackage(extended, extended.size()).status == PackageReadability::Malformed);
|
||||
// And a file size that disagrees with the same bytes.
|
||||
CHECK(decodePackage(file, file.size() + 1).status == PackageReadability::Malformed);
|
||||
CHECK(decodePackage(file, file.size() - 1).status == PackageReadability::Malformed);
|
||||
}
|
||||
|
||||
// --- header defaults ---------------------------------------------------------
|
||||
|
||||
// The 0/0 defaults are not the current ladder pair, so a header that never
|
||||
// parsed cannot be mistaken for a plausible 1/1. They mean "unset", NOT "the
|
||||
// decode failed": a decode that got past the header reports the real pair
|
||||
// alongside its Malformed verdict.
|
||||
static void testHeaderDefaultsMeanUnparsed() {
|
||||
CHECK(PackageHeader{}.formatVersion == 0);
|
||||
CHECK(PackageHeader{}.minReaderVersion == 0);
|
||||
CHECK(PackageHeader{}.writerVersion.empty());
|
||||
|
||||
// Failed before the header: bad magic, and a semver truncated mid-string.
|
||||
std::vector<std::uint8_t> badMagic = rawHeader(1, 1, "1.0.0");
|
||||
appendManifest(badMagic, "{}");
|
||||
badMagic[0] = 'Z';
|
||||
const DecodedPackage magic = decodePackage(badMagic, badMagic.size());
|
||||
CHECK(magic.status == PackageReadability::Malformed);
|
||||
CHECK(magic.header == PackageHeader{});
|
||||
|
||||
std::vector<std::uint8_t> cutSemver = rawHeader(1, 1, "1.0.0");
|
||||
cutSemver.resize(18);
|
||||
CHECK(decodePackage(cutSemver, cutSemver.size()).header == PackageHeader{});
|
||||
|
||||
// Failed after it: a same-version package with a corrupt manifest is
|
||||
// Malformed, and its header is fully populated.
|
||||
std::vector<std::uint8_t> corrupt =
|
||||
rawHeader(kPackageFormatVersion, kPackageMinReaderVersion, "1.0.0");
|
||||
appendManifest(corrupt, "not json");
|
||||
const DecodedPackage late = decodePackage(corrupt, corrupt.size());
|
||||
CHECK(late.status == PackageReadability::Malformed);
|
||||
CHECK(late.header.formatVersion == kPackageFormatVersion);
|
||||
CHECK(late.header.minReaderVersion == kPackageMinReaderVersion);
|
||||
CHECK(late.header.writerVersion == "1.0.0");
|
||||
}
|
||||
|
||||
// --- version ladder: TooNew refuses whole ------------------------------------
|
||||
|
||||
static void testTooNewProducesNoManifest() {
|
||||
// A future structural format: only the frozen region is trustworthy, so the
|
||||
// tail is deliberate garbage that would crash a parser that kept reading.
|
||||
std::vector<std::uint8_t> bytes = rawHeader(9, 9, "9.9.9");
|
||||
for (int i = 0; i < 32; ++i) bytes.push_back(0xFF);
|
||||
|
||||
const DecodedPackage dec = decodePackage(bytes, bytes.size());
|
||||
CHECK(dec.status == PackageReadability::TooNew);
|
||||
// The refusal message's three facts survive...
|
||||
CHECK(dec.header.formatVersion == 9);
|
||||
CHECK(dec.header.minReaderVersion == 9);
|
||||
CHECK(dec.header.writerVersion == "9.9.9");
|
||||
// ...and nothing else is produced: no manifest, no layout, no half-success.
|
||||
CHECK(dec.manifest.entries.empty());
|
||||
CHECK(dec.manifest == PackageManifest{});
|
||||
CHECK(dec.layout.empty());
|
||||
CHECK(dec.prefixSize == 0);
|
||||
|
||||
// Boundary: minReader exactly one past this build.
|
||||
auto boundary = rawHeader(kPackageFormatVersion + 1, kPackageFormatVersion + 1, "2.0.0");
|
||||
CHECK(decodePackage(boundary, boundary.size()).status == PackageReadability::TooNew);
|
||||
|
||||
// A TooNew header truncated inside the frozen region cannot name the
|
||||
// writer, so it is Malformed, not an unactionable refusal.
|
||||
std::vector<std::uint8_t> cut = rawHeader(9, 9, "9.9.9");
|
||||
cut.resize(18); // mid-semver
|
||||
CHECK(decodePackage(cut, cut.size()).status == PackageReadability::Malformed);
|
||||
}
|
||||
|
||||
// An additively-tagged package (fv > ours, minReader still within reach) whose
|
||||
// manifest fails to parse: the header classifies Readable, so decode reads
|
||||
// into the manifest and fails there. That failure must still report TooNew —
|
||||
// the header is valid and already carries the writer's semver — not the
|
||||
// unactionable Malformed a genuinely corrupt header produces.
|
||||
static void testAdditiveUnparseableManifestIsTooNew() {
|
||||
std::vector<std::uint8_t> bytes = rawHeader(kPackageFormatVersion + 1, kPackageMinReaderVersion, "1.9.0");
|
||||
appendManifest(bytes, "not json");
|
||||
const DecodedPackage dec = decodePackage(bytes, bytes.size());
|
||||
CHECK(dec.status == PackageReadability::TooNew);
|
||||
CHECK(dec.header.formatVersion == kPackageFormatVersion + 1);
|
||||
CHECK(dec.header.minReaderVersion == kPackageMinReaderVersion);
|
||||
CHECK(dec.header.writerVersion == "1.9.0");
|
||||
CHECK(dec.manifest.entries.empty());
|
||||
CHECK(dec.layout.empty());
|
||||
|
||||
// Same-version unparseable manifest stays Malformed: nothing "newer"
|
||||
// excuses it, so this is not a blanket "unparseable == TooNew" rule.
|
||||
std::vector<std::uint8_t> sameVersion =
|
||||
rawHeader(kPackageFormatVersion, kPackageMinReaderVersion, "1.0.0");
|
||||
appendManifest(sameVersion, "not json");
|
||||
CHECK(decodePackage(sameVersion, sameVersion.size()).status == PackageReadability::Malformed);
|
||||
}
|
||||
|
||||
// --- version ladder: additive forward compatibility --------------------------
|
||||
|
||||
// The reason two integers exist: a NEWER formatVersion whose minReaderVersion
|
||||
// still reaches back to this build must read, with its unknown keys skipped —
|
||||
// at the manifest level AND inside the nested Sample blob, the actual
|
||||
// motivating case for the two-integer ladder (see this directory's CLAUDE.md).
|
||||
static void testNewerAdditiveFormatReads() {
|
||||
const std::string manifest = handManifest(
|
||||
"a.wav", 4,
|
||||
"\"instrumentState\":{\"future\":[1,2,3]},\"anotherNewKey\":\"x\",",
|
||||
"\"someFutureSampleField\":42,");
|
||||
std::vector<std::uint8_t> bytes =
|
||||
rawHeader(kPackageFormatVersion + 1, kPackageMinReaderVersion, "1.9.0");
|
||||
appendManifest(bytes, manifest);
|
||||
const std::uint64_t total = bytes.size() + 4; // the one entry's payload
|
||||
|
||||
const DecodedPackage dec = decodePackage(bytes, total);
|
||||
CHECK(dec.status == PackageReadability::Readable);
|
||||
CHECK(dec.header.formatVersion == kPackageFormatVersion + 1);
|
||||
CHECK(dec.header.writerVersion == "1.9.0");
|
||||
CHECK(dec.manifest.entries.size() == 1);
|
||||
CHECK(dec.manifest.entries[0].fileName == "a.wav");
|
||||
CHECK(dec.manifest.entries[0].sample.id == "s1");
|
||||
CHECK(dec.layout.size() == 1);
|
||||
CHECK(dec.layout[0].offset == bytes.size());
|
||||
CHECK(dec.layout[0].length == 4);
|
||||
}
|
||||
|
||||
// --- hostile headers ---------------------------------------------------------
|
||||
|
||||
static void testHostileHeadersAreMalformed() {
|
||||
// Wrong magic.
|
||||
std::vector<std::uint8_t> bad = rawHeader(1, 1, "1.0.0");
|
||||
appendManifest(bad, "{}");
|
||||
bad[0] = 'Z';
|
||||
CHECK(decodePackage(bad, bad.size()).status == PackageReadability::Malformed);
|
||||
|
||||
// Incoherent version pairs (the classify rules, proven through the framing).
|
||||
for (auto [fv, mv] : {std::pair<std::uint32_t, std::uint32_t>{0, 0}, {0, 1},
|
||||
{1, 0}, {1, 2}}) {
|
||||
std::vector<std::uint8_t> b = rawHeader(fv, mv, "1.0.0");
|
||||
appendManifest(b, "{}");
|
||||
CHECK(decodePackage(b, b.size()).status == PackageReadability::Malformed);
|
||||
}
|
||||
|
||||
// A forged semver length over the cap must not read past the buffer.
|
||||
std::vector<std::uint8_t> overSemver;
|
||||
overSemver.insert(overSemver.end(), kPackageMagic, kPackageMagic + 4);
|
||||
wire::putLE(overSemver, std::uint32_t{1});
|
||||
wire::putLE(overSemver, std::uint32_t{1});
|
||||
wire::putLE(overSemver, kMaxWriterVersionBytes + 1);
|
||||
CHECK(decodePackage(overSemver, overSemver.size()).status ==
|
||||
PackageReadability::Malformed);
|
||||
|
||||
// A forged manifest length over the cap: refused before any allocation.
|
||||
std::vector<std::uint8_t> overManifest = rawHeader(1, 1, "1.0.0");
|
||||
wire::putLE(overManifest, kMaxManifestBytes + 1);
|
||||
CHECK(decodePackage(overManifest, overManifest.size()).status ==
|
||||
PackageReadability::Malformed);
|
||||
|
||||
// A manifest whose entry name expresses a path: rejected on decode even
|
||||
// though no current encoder would write it.
|
||||
std::vector<std::uint8_t> traversal = rawHeader(1, 1, "1.0.0");
|
||||
appendManifest(traversal, handManifest("../evil.wav", 4, ""));
|
||||
CHECK(decodePackage(traversal, traversal.size() + 4).status ==
|
||||
PackageReadability::Malformed);
|
||||
}
|
||||
|
||||
// --- requiredPrefixSize ------------------------------------------------------
|
||||
|
||||
static void testRequiredPrefixSizeGrowsToTheFullPrefix() {
|
||||
auto enc = encodePackage(fixture(3, 5));
|
||||
CHECK(enc.has_value());
|
||||
const auto& prefix = enc->prefix;
|
||||
|
||||
// Empty: the fixed region first.
|
||||
CHECK(requiredPrefixSize({}) == std::uint64_t{16});
|
||||
|
||||
// With the fixed region: asks through the semver + manifest-length field.
|
||||
const std::string& semver = version::stampVersion();
|
||||
std::vector<std::uint8_t> first16(prefix.begin(), prefix.begin() + 16);
|
||||
CHECK(requiredPrefixSize(first16) == std::uint64_t{16 + semver.size() + 4});
|
||||
|
||||
// With that much: the full prefix size. And the answer is a fixpoint.
|
||||
std::vector<std::uint8_t> upToManifestLen(
|
||||
prefix.begin(), prefix.begin() + 20 + static_cast<long>(semver.size()));
|
||||
CHECK(requiredPrefixSize(upToManifestLen) == std::uint64_t{prefix.size()});
|
||||
CHECK(requiredPrefixSize(prefix) == std::uint64_t{prefix.size()});
|
||||
|
||||
// The returned count is exactly enough for decodePackage.
|
||||
CHECK(decodePackage(prefix, enc->totalSize).status == PackageReadability::Readable);
|
||||
}
|
||||
|
||||
static void testRequiredPrefixSizeRefusals() {
|
||||
// Bad magic: stop reading.
|
||||
std::vector<std::uint8_t> bad(16, 0);
|
||||
CHECK(!requiredPrefixSize(bad).has_value());
|
||||
|
||||
// Incoherent versions: stop reading.
|
||||
std::vector<std::uint8_t> zeroed = rawHeader(0, 0, "1.0.0");
|
||||
CHECK(!requiredPrefixSize(zeroed).has_value());
|
||||
|
||||
// TooNew: asks only through the frozen region — the manifest-length field
|
||||
// belongs to the newer format and is never trusted.
|
||||
std::vector<std::uint8_t> tooNew = rawHeader(9, 9, "9.9.9");
|
||||
for (int i = 0; i < 8; ++i) tooNew.push_back(0xFF); // garbage where M would be
|
||||
CHECK(requiredPrefixSize(tooNew) == std::uint64_t{16 + 5});
|
||||
|
||||
// Oversize length fields: stop reading.
|
||||
std::vector<std::uint8_t> overSemver;
|
||||
overSemver.insert(overSemver.end(), kPackageMagic, kPackageMagic + 4);
|
||||
wire::putLE(overSemver, std::uint32_t{1});
|
||||
wire::putLE(overSemver, std::uint32_t{1});
|
||||
wire::putLE(overSemver, kMaxWriterVersionBytes + 1);
|
||||
CHECK(!requiredPrefixSize(overSemver).has_value());
|
||||
|
||||
std::vector<std::uint8_t> overManifest = rawHeader(1, 1, "1.0.0");
|
||||
wire::putLE(overManifest, kMaxManifestBytes + 1);
|
||||
CHECK(!requiredPrefixSize(overManifest).has_value());
|
||||
}
|
||||
|
||||
int main() {
|
||||
testEncodeDecodeRoundTrip();
|
||||
testEncodeRefusesWhatManifestRefuses();
|
||||
testTruncationAtEveryByteOffsetIsMalformed();
|
||||
testHeaderDefaultsMeanUnparsed();
|
||||
testTooNewProducesNoManifest();
|
||||
testAdditiveUnparseableManifestIsTooNew();
|
||||
testNewerAdditiveFormatReads();
|
||||
testHostileHeadersAreMalformed();
|
||||
testRequiredPrefixSizeRefusals();
|
||||
testRequiredPrefixSizeGrowsToTheFullPrefix();
|
||||
|
||||
if (g_fail == 0) {
|
||||
std::printf("bank_package_tests: all passed\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("bank_package_tests: %d failure(s)\n", g_fail);
|
||||
return 1;
|
||||
}
|
||||
@@ -241,6 +241,41 @@ static void testEveryAwkwardStemStaysFilesystemLegal() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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();
|
||||
@@ -268,6 +303,12 @@ int main() {
|
||||
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;
|
||||
|
||||
@@ -362,6 +362,39 @@ static void testBankRelativeForNameMatchesDerivePathSpelling() {
|
||||
CHECK(bankRelativeForName(p.fileName) == p.relativePath);
|
||||
}
|
||||
|
||||
// --- deriveRenderPaths ------------------------------------------------------
|
||||
|
||||
static void testRenderPathsSpellTheStemExactlyAsTheBankPathDoes() {
|
||||
// The one owner claim, made checkable: for the same baseName + uniqueTag, the
|
||||
// bank path's stem and file name must BE the render path's. If these ever
|
||||
// diverge, bankRelativeForName's exact-string match against an enumerated
|
||||
// folder entry starts misfiring and prune misreads referenced files as orphans.
|
||||
const BankPaths bank = deriveBankPaths("/proj", "kick drum!", "001");
|
||||
const RenderPaths render = deriveRenderPaths("/proj/reasampler_bank",
|
||||
"kick drum!", "001");
|
||||
CHECK(render.fileStem == bank.fileStem);
|
||||
CHECK(render.fileName == bank.fileName);
|
||||
CHECK(render.absoluteDir == bank.absoluteDir);
|
||||
}
|
||||
|
||||
static void testRenderPathsTakeTheirDirectoryVerbatim() {
|
||||
// No bank subfolder is appended — a render outside the bank has none, which is
|
||||
// what makes "write into the bank folder" inexpressible through this call.
|
||||
const RenderPaths r = deriveRenderPaths("/proj/media/", "take", "");
|
||||
CHECK(r.absoluteDir == normalizeSlashes("/proj/media"));
|
||||
CHECK(r.fileName == "take.wav");
|
||||
CHECK(r.fileStem == "take");
|
||||
// Backslashes normalize and a trailing slash is stripped, same as everywhere.
|
||||
CHECK(deriveRenderPaths("C:\\proj\\media\\", "take", "").absoluteDir ==
|
||||
normalizeSlashes("C:/proj/media"));
|
||||
}
|
||||
|
||||
static void testRenderPathsEmptyDirectoryStaysEmpty() {
|
||||
// No CWD fallback: an unresolvable directory must fail at the caller's own
|
||||
// guard, never silently render next to whatever the process happened to be in.
|
||||
CHECK(deriveRenderPaths("", "take", "001").absoluteDir.empty());
|
||||
}
|
||||
|
||||
static void testBankRelativeForNameConventionAndEdge() {
|
||||
// The convention verbatim: "reasampler_bank/<name>" (the one place the spelling lives).
|
||||
CHECK(bankRelativeForName("a.wav") == "reasampler_bank/a.wav");
|
||||
@@ -396,6 +429,9 @@ int main() {
|
||||
testTransitionFirstSaveOfUnsavedRelocatesButPlanNoOps();
|
||||
testTransitionInPlaceSaveIsNoOp();
|
||||
testBankRelativeForNameMatchesDerivePathSpelling();
|
||||
testRenderPathsSpellTheStemExactlyAsTheBankPathDoes();
|
||||
testRenderPathsTakeTheirDirectoryVerbatim();
|
||||
testRenderPathsEmptyDirectoryStaysEmpty();
|
||||
testBankRelativeForNameConventionAndEdge();
|
||||
|
||||
if (g_fail == 0) std::printf("capture_paths: all tests passed\n");
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
// 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,
|
||||
const std::string& displayName = "") {
|
||||
model::Sample s;
|
||||
s.id = id;
|
||||
s.displayName = displayName.empty() ? id : displayName;
|
||||
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);
|
||||
// Point-in-time alive() == 0 alone cannot fail on a whole-package-in-memory
|
||||
// shape (N buffers allocated and freed one at a time still ends at 0); the
|
||||
// high-water mark can, across this three-entry export and everything the test
|
||||
// binary ran before it — it must never exceed the "at most one payload" claim.
|
||||
CHECK(PayloadBuffer::highWaterMark() == 1);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// Every occurrence of `"key":"value"` in `json`, value returned raw (escape-aware
|
||||
// only enough to not stop early on an escaped quote — this file's own writer output
|
||||
// never nests an unescaped quote, so that is sufficient here).
|
||||
static std::vector<std::string> jsonStringValuesForKey(const std::string& json,
|
||||
const std::string& key) {
|
||||
std::vector<std::string> values;
|
||||
const std::string marker = "\"" + key + "\":\"";
|
||||
std::size_t pos = 0;
|
||||
while ((pos = json.find(marker, pos)) != std::string::npos) {
|
||||
std::size_t i = pos + marker.size();
|
||||
while (i < json.size() && json[i] != '"') {
|
||||
if (json[i] == '\\') ++i; // skip the escaped char too
|
||||
++i;
|
||||
}
|
||||
values.push_back(json.substr(pos + marker.size(), i - (pos + marker.size())));
|
||||
pos = i;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
// True for a value opening with an <alpha>':' drive-relative prefix (":" alone is
|
||||
// JSON's own key separator, so this is checked on isolated VALUES, never on raw text).
|
||||
static bool looksLikeDriveForm(const std::string& value) {
|
||||
return value.size() >= 2 && std::isalpha(static_cast<unsigned char>(value[0])) &&
|
||||
value[1] == ':';
|
||||
}
|
||||
|
||||
static void testEmittedManifestBytesCarryNoPath() {
|
||||
Fixture fx("nopath");
|
||||
// The bank's own display name AND a sample's displayName each carry a literal
|
||||
// '/' — free text, unlike the entry `name` / nested `relativePath` fields this
|
||||
// test actually polices (docs/product/bank-package.md:282-289: the destination is
|
||||
// derived from the entry name, never from free text). Present in the fixture so
|
||||
// the scan below proves it is scoped correctly rather than merely holding by
|
||||
// accident on names that happen not to collide with the rule.
|
||||
CHECK(fx.session.book().renameBank(fx.bankId, "Drums/Bus"));
|
||||
// Both source records carry a directory component; neither may reach the file.
|
||||
fx.addSample("s1", "kick.wav", 200, 3, /*writeToDisk=*/true, "Kick / alt take");
|
||||
fx.addSample("s2", "snare take 2.wav", 200, 9);
|
||||
CHECK(exportBank(fx.session, fx.request()).status == ExportStatus::Written);
|
||||
|
||||
// Locate the MANIFEST REGION of the emitted file 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
|
||||
|
||||
// The boundary, pinned rather than assumed: free text legitimately carries '/'.
|
||||
CHECK(manifest.find("Drums/Bus") != std::string::npos);
|
||||
CHECK(manifest.find("Kick / alt take") != std::string::npos);
|
||||
|
||||
// The rule itself: scoped to the two fields the importer derives a destination
|
||||
// from — the entry `name` and the nested Sample's own `relativePath` — never to
|
||||
// `displayName` or the manifest's `bankDisplayName`.
|
||||
const std::vector<std::string> names = jsonStringValuesForKey(manifest, "name");
|
||||
const std::vector<std::string> relPaths = jsonStringValuesForKey(manifest, "relativePath");
|
||||
CHECK(!names.empty());
|
||||
CHECK(!relPaths.empty());
|
||||
for (const std::string& v : names) {
|
||||
CHECK(v.find('/') == std::string::npos);
|
||||
CHECK(v.find('\\') == std::string::npos);
|
||||
CHECK(v.find("..") == std::string::npos);
|
||||
CHECK(!looksLikeDriveForm(v));
|
||||
}
|
||||
for (const std::string& v : relPaths) {
|
||||
CHECK(v.find('/') == std::string::npos);
|
||||
CHECK(v.find('\\') == std::string::npos);
|
||||
CHECK(v.find("..") == std::string::npos);
|
||||
CHECK(!looksLikeDriveForm(v));
|
||||
}
|
||||
}
|
||||
|
||||
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 testCommitFailureIsAGenuineMidWriteAbandon() {
|
||||
// The mid-READ injection above (a source vanishing between digest and stream) is
|
||||
// not what "mid-write" names in export_bank.cpp:115-118/122-125 — those guard a
|
||||
// failure IN the write itself: appendPayload's stream going bad, or commit's
|
||||
// rename failing. A directory squatting on the destination (test_package_io.cpp's
|
||||
// own precedent for a real, not simulated, commit failure) makes every payload
|
||||
// stream fine and only the final rename fail.
|
||||
Fixture fx("commitfail");
|
||||
fx.addSample("s1", "kick.wav", 300, 1);
|
||||
fx.addSample("s2", "snare.wav", 300, 2);
|
||||
|
||||
std::error_code ec;
|
||||
fs::create_directory(utf8Path(fx.destPath()), ec);
|
||||
CHECK(!ec);
|
||||
|
||||
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());
|
||||
|
||||
const ExportOutcome out = writePackageFile(*encoded, manifest, sources, fx.destPath());
|
||||
CHECK(out.status == ExportStatus::WriteFailed);
|
||||
CHECK(fs::is_directory(utf8Path(fx.destPath()))); // the squatting dir is untouched
|
||||
CHECK(!exists(fx.destPath() + ".rsbanktmp")); // commit()'s own self-clean ran
|
||||
CHECK(PayloadBuffer::alive() == 0);
|
||||
|
||||
fs::remove(utf8Path(fx.destPath()), ec);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// saveToActiveProject writes seven keys (shell/persist/ext_state_io.cpp): `banks`,
|
||||
// the legacy-key clear, `view_state`, the tail setting, the tracking ledger, the
|
||||
// version stamp, and the bank-generation counter. This asserts byte-identity of
|
||||
// the three that have an in-memory string to diff (`banks`, `view_state`, the tail
|
||||
// setting) plus bankGeneration() (the bank-generation-counter key IS its
|
||||
// serialization). The legacy-key clear and the version stamp are session-external,
|
||||
// nothing here to diff against. The tracking ledger has no public accessor to diff
|
||||
// either, but needs none: exportBank/digestSources/writePackageFile all take the
|
||||
// session by `const&`, and ReaSamplerSession::recordCreated — the ledger's one
|
||||
// writer (session.h) — is non-const, so it is not reachable through this call at
|
||||
// all; the compiler enforces "untouched" here rather than a runtime check proving it.
|
||||
const std::string bankBookBefore = fx.session.book().serialize();
|
||||
const std::string viewBefore = fx.session.view().serialize();
|
||||
const std::string tailBefore = capture::serializeTailSetting(fx.session.tail());
|
||||
const std::int64_t generationBefore = fx.session.bankGeneration();
|
||||
|
||||
CHECK(exportBank(fx.session, fx.request()).status == ExportStatus::Written);
|
||||
|
||||
CHECK(fx.session.book().serialize() == bankBookBefore);
|
||||
CHECK(fx.session.view().serialize() == viewBefore);
|
||||
CHECK(capture::serializeTailSetting(fx.session.tail()) == tailBefore);
|
||||
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();
|
||||
testCommitFailureIsAGenuineMidWriteAbandon();
|
||||
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,290 @@
|
||||
// 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 testUniqueNameSurvivesLongExtensionUnderflow() {
|
||||
// insertSuffix computes room = kMaxEntryNameBytes - suffix.size() - ext.size() in
|
||||
// size_t; an extension long enough that even a two-digit "_10" suffix pushes the
|
||||
// sum past the cap must not wrap that subtraction. Ten same-named entries force
|
||||
// the tenth collision into double digits against a 253-byte extension (253 + 3 =
|
||||
// 256, one over kMaxEntryNameBytes).
|
||||
const std::string hostileName = "a." + std::string(252, 'x'); // 254 bytes, otherwise valid
|
||||
std::vector<ExportCandidate> candidates;
|
||||
for (int i = 0; i < 10; ++i)
|
||||
candidates.push_back(present("s" + std::to_string(i), "reasampler_bank/" + hostileName));
|
||||
|
||||
const ExportPlan p = planExport(bankOf(candidates));
|
||||
CHECK(p.verdict == ExportVerdict::Ready);
|
||||
CHECK(p.manifest.entries.size() == 10);
|
||||
for (const PackageEntry& e : p.manifest.entries) CHECK(isValidEntryName(e.fileName));
|
||||
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));
|
||||
}
|
||||
|
||||
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();
|
||||
testUniqueNameSurvivesLongExtensionUnderflow();
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
// Standalone tests for reasampler's per-FX offline restore resolution — no
|
||||
// REAPER, no test framework.
|
||||
//
|
||||
// The property under test: a captured FX-offline state lands on the FX it was
|
||||
// captured FROM, whatever that FX's slot has become while the track was parked.
|
||||
// Every scenario below is a chain mutation performed while parked.
|
||||
|
||||
#include "../src/core/view/fx_offline.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
using namespace reasampler;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
// -- helpers -----------------------------------------------------------------
|
||||
|
||||
// The ops the restore planner emits for one track from an identity-keyed
|
||||
// snapshot: capture-order entries, each carrying the FX's own identity.
|
||||
static std::vector<FxOfflineOp> identityOps(
|
||||
const std::vector<std::pair<std::string, bool>>& captured) {
|
||||
std::vector<FxOfflineOp> ops;
|
||||
for (std::size_t i = 0; i < captured.size(); ++i) {
|
||||
ops.push_back(FxOfflineOp{"{TRACK}", FxKeying::Identity, captured[i].first,
|
||||
static_cast<int>(i), captured[i].second});
|
||||
}
|
||||
return ops;
|
||||
}
|
||||
|
||||
// The ops a snapshot lifted from a pre-identity project yields: no identities,
|
||||
// position is the slot.
|
||||
static std::vector<FxOfflineOp> slotOps(const std::vector<bool>& captured) {
|
||||
std::vector<FxOfflineOp> ops;
|
||||
for (std::size_t i = 0; i < captured.size(); ++i) {
|
||||
ops.push_back(FxOfflineOp{"{TRACK}", FxKeying::Slot, {},
|
||||
static_cast<int>(i), captured[i]});
|
||||
}
|
||||
return ops;
|
||||
}
|
||||
|
||||
static bool hasWrite(const FxRestoreResolution& res, int fxIndex, bool offline) {
|
||||
for (const FxOfflineWrite& w : res.writes)
|
||||
if (w.fxIndex == fxIndex) return w.offline == offline;
|
||||
return false;
|
||||
}
|
||||
|
||||
static bool writesTouch(const FxRestoreResolution& res, int fxIndex) {
|
||||
for (const FxOfflineWrite& w : res.writes)
|
||||
if (w.fxIndex == fxIndex) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// -- 1. Chain reordered while parked -----------------------------------------
|
||||
|
||||
static void testReorderedChainRestoresEachPluginItsOwnState() {
|
||||
// Captured with A, B, C in slots 0,1,2 — B was already offline pre-park.
|
||||
const std::vector<FxOfflineOp> ops =
|
||||
identityOps({{"{A}", false}, {"{B}", true}, {"{C}", false}});
|
||||
|
||||
// While parked the user dragged C to the front: the chain is now C, A, B.
|
||||
const FxRestoreResolution res = resolveFxRestore(ops, {"{C}", "{A}", "{B}"});
|
||||
|
||||
CHECK(res.writes.size() == 3);
|
||||
CHECK(res.drops.total() == 0);
|
||||
CHECK(hasWrite(res, 1, false)); // A, now at slot 1
|
||||
CHECK(hasWrite(res, 2, true)); // B's offline state followed B to slot 2
|
||||
CHECK(hasWrite(res, 0, false)); // C, now at slot 0
|
||||
|
||||
// The slot-keyed reading of the SAME capture is what the old code did: it
|
||||
// would have written B's `true` to slot 1, which is now A. Pinning the
|
||||
// divergence keeps a well-meant "just use the index" from coming back.
|
||||
const FxRestoreResolution bySlot =
|
||||
resolveFxRestore(slotOps({false, true, false}), {"{C}", "{A}", "{B}"});
|
||||
CHECK(hasWrite(bySlot, 1, true)); // the defect, reproduced deliberately
|
||||
}
|
||||
|
||||
// -- 2. FX deleted while parked ----------------------------------------------
|
||||
|
||||
static void testDeletedFxDropsExplicitlyAndTouchesNothingElse() {
|
||||
// B (true) and C (false) carry OPPOSITE captured states — the discriminator.
|
||||
// A test where both carried `true` couldn't tell "C's own state followed it
|
||||
// to slot 1" apart from "B's dropped state leaked onto whatever moved there".
|
||||
const std::vector<FxOfflineOp> ops =
|
||||
identityOps({{"{A}", false}, {"{B}", true}, {"{C}", false}});
|
||||
|
||||
// B was deleted while parked; A and C closed the gap.
|
||||
const FxRestoreResolution res = resolveFxRestore(ops, {"{A}", "{C}"});
|
||||
|
||||
CHECK(res.writes.size() == 2);
|
||||
CHECK(res.drops.missingIdentity == 1);
|
||||
CHECK(res.drops.unidentified == 0);
|
||||
CHECK(res.drops.slotOutOfRange == 0);
|
||||
CHECK(hasWrite(res, 0, false)); // A
|
||||
// C's OWN captured `false` landed at slot 1, not B's dropped `true`.
|
||||
CHECK(hasWrite(res, 1, false));
|
||||
|
||||
// The drop is reportable, not silent.
|
||||
const std::string msg = describeFxRestoreDrops(res.drops, /*trackCount=*/1);
|
||||
CHECK(!msg.empty());
|
||||
CHECK(msg.find("1 captured FX offline state(s) on 1 track(s)") != std::string::npos);
|
||||
CHECK(msg.find("no longer in the chain") != std::string::npos);
|
||||
CHECK(msg.back() == '\n');
|
||||
|
||||
// Nothing dropped ⇒ nothing said.
|
||||
CHECK(describeFxRestoreDrops(FxRestoreDrops{}, 0).empty());
|
||||
}
|
||||
|
||||
static void testDescribeNamesAllThreeDropKinds() {
|
||||
FxRestoreDrops drops;
|
||||
drops.missingIdentity = 2;
|
||||
drops.unidentified = 1;
|
||||
drops.slotOutOfRange = 3;
|
||||
CHECK(drops.total() == 6);
|
||||
|
||||
const std::string msg = describeFxRestoreDrops(drops, /*trackCount=*/2);
|
||||
CHECK(msg.find("6 captured FX offline state(s) on 2 track(s)") != std::string::npos);
|
||||
CHECK(msg.find("2 FX no longer in the chain") != std::string::npos);
|
||||
CHECK(msg.find("1 FX REAPER could not identify at capture time") != std::string::npos);
|
||||
CHECK(msg.find("3 from a project saved before FX identity was recorded")
|
||||
!= std::string::npos);
|
||||
}
|
||||
|
||||
// -- 3. FX added while parked -------------------------------------------------
|
||||
|
||||
static void testAddedFxIsNotTouched() {
|
||||
const std::vector<FxOfflineOp> ops = identityOps({{"{A}", false}, {"{B}", true}});
|
||||
|
||||
// D was inserted at the FRONT while parked — the case where an index-keyed
|
||||
// restore would have written every captured state onto the wrong plugin.
|
||||
const FxRestoreResolution res = resolveFxRestore(ops, {"{D}", "{A}", "{B}"});
|
||||
|
||||
CHECK(res.writes.size() == 2);
|
||||
CHECK(res.drops.total() == 0);
|
||||
CHECK(hasWrite(res, 1, false)); // A
|
||||
CHECK(hasWrite(res, 2, true)); // B
|
||||
CHECK(!writesTouch(res, 0)); // D is never written at all
|
||||
}
|
||||
|
||||
// -- 4. Two instances of the SAME plugin type --------------------------------
|
||||
|
||||
static void testTwoInstancesOfOnePluginKeyIndependently() {
|
||||
// Two copies of one plugin: distinct instances, distinct identities, and the
|
||||
// two carry OPPOSITE captured states — a scheme keyed on plugin type or name
|
||||
// could not tell them apart and would restore both the same way.
|
||||
const std::vector<FxOfflineOp> ops =
|
||||
identityOps({{"{EQ-1}", true}, {"{EQ-2}", false}, {"{COMP}", false}});
|
||||
|
||||
// Swapped while parked: EQ-2, COMP, EQ-1.
|
||||
const FxRestoreResolution res = resolveFxRestore(ops, {"{EQ-2}", "{COMP}", "{EQ-1}"});
|
||||
|
||||
CHECK(res.writes.size() == 3);
|
||||
CHECK(res.drops.total() == 0);
|
||||
CHECK(hasWrite(res, 2, true)); // EQ-1's offline=true followed it to slot 2
|
||||
CHECK(hasWrite(res, 0, false)); // EQ-2 stayed online at slot 0
|
||||
CHECK(hasWrite(res, 1, false)); // COMP
|
||||
}
|
||||
|
||||
// -- 5. Slot-keyed (pre-identity) snapshots ----------------------------------
|
||||
|
||||
static void testSlotKeyedSnapshotRestoresByPositionAndBoundsChecks() {
|
||||
// All a pre-identity blob's bytes can support: position addressing.
|
||||
const FxRestoreResolution res =
|
||||
resolveFxRestore(slotOps({false, true}), {"{A}", "{B}", "{C}"});
|
||||
CHECK(res.writes.size() == 2);
|
||||
CHECK(res.drops.total() == 0);
|
||||
CHECK(hasWrite(res, 0, false));
|
||||
CHECK(hasWrite(res, 1, true));
|
||||
CHECK(!writesTouch(res, 2)); // an FX the snapshot never covered stays untouched
|
||||
|
||||
// A slot that no longer exists is dropped and counted, never clamped.
|
||||
const FxRestoreResolution shrunk =
|
||||
resolveFxRestore(slotOps({false, true, true}), {"{A}"});
|
||||
CHECK(shrunk.writes.size() == 1);
|
||||
CHECK(shrunk.drops.slotOutOfRange == 2);
|
||||
CHECK(shrunk.drops.missingIdentity == 0);
|
||||
}
|
||||
|
||||
// -- 6. Degenerate inputs -----------------------------------------------------
|
||||
|
||||
static void testUnresolvableIdentityNeverFallsBackToItsSlot() {
|
||||
// An identity-keyed entry with NO identity (REAPER reported none at capture)
|
||||
// is a drop — the slot it happens to carry must not be used as a substitute.
|
||||
// Counted as `unidentified`, not `missingIdentity`: unlike a real captured
|
||||
// identity going missing, this FX may still be sitting right there — we
|
||||
// simply never had a name for it, and the report must say that, not "no
|
||||
// longer in the chain".
|
||||
std::vector<FxOfflineOp> ops = identityOps({{"{A}", false}});
|
||||
ops.push_back(FxOfflineOp{"{TRACK}", FxKeying::Identity, "", /*slot=*/1, /*offline=*/true});
|
||||
|
||||
const FxRestoreResolution res = resolveFxRestore(ops, {"{A}", "{B}"});
|
||||
CHECK(res.writes.size() == 1);
|
||||
CHECK(hasWrite(res, 0, false));
|
||||
CHECK(!writesTouch(res, 1)); // {B} would have been the slot-1 victim
|
||||
CHECK(res.drops.missingIdentity == 0);
|
||||
CHECK(res.drops.unidentified == 1);
|
||||
|
||||
const std::string msg = describeFxRestoreDrops(res.drops, /*trackCount=*/1);
|
||||
CHECK(msg.find("1 FX REAPER could not identify at capture time") != std::string::npos);
|
||||
CHECK(msg.find("no longer in the chain") == std::string::npos); // not this FX's story
|
||||
}
|
||||
|
||||
static void testLiveFxWithNoIdentityIsNeverARestoreTarget() {
|
||||
// The mirror case: a live FX REAPER reports no GUID for cannot be matched by
|
||||
// an empty captured identity either.
|
||||
const FxRestoreResolution res =
|
||||
resolveFxRestore(identityOps({{"{A}", true}}), {"", "{A}"});
|
||||
CHECK(res.writes.size() == 1);
|
||||
CHECK(hasWrite(res, 1, true));
|
||||
CHECK(!writesTouch(res, 0));
|
||||
CHECK(res.drops.total() == 0);
|
||||
}
|
||||
|
||||
static void testEmptyInputsProduceNoWrites() {
|
||||
CHECK(resolveFxRestore({}, {"{A}"}).writes.empty());
|
||||
CHECK(resolveFxRestore({}, {}).drops.total() == 0);
|
||||
|
||||
// Every FX gone (the whole chain cleared while parked): all dropped, none written.
|
||||
const FxRestoreResolution res =
|
||||
resolveFxRestore(identityOps({{"{A}", true}, {"{B}", false}}), {});
|
||||
CHECK(res.writes.empty());
|
||||
CHECK(res.drops.missingIdentity == 2);
|
||||
}
|
||||
|
||||
static void testDuplicateLiveIdentityResolvesToTheFirstSlotOnly() {
|
||||
// Not producible by REAPER (one GUID per instance) — pinned so a corrupt or
|
||||
// hand-edited chain writes once, deterministically, instead of twice.
|
||||
const FxRestoreResolution res =
|
||||
resolveFxRestore(identityOps({{"{A}", true}}), {"{A}", "{A}"});
|
||||
CHECK(res.writes.size() == 1);
|
||||
CHECK(hasWrite(res, 0, true));
|
||||
CHECK(!writesTouch(res, 1));
|
||||
}
|
||||
|
||||
int main() {
|
||||
testReorderedChainRestoresEachPluginItsOwnState();
|
||||
testDeletedFxDropsExplicitlyAndTouchesNothingElse();
|
||||
testDescribeNamesAllThreeDropKinds();
|
||||
testAddedFxIsNotTouched();
|
||||
testTwoInstancesOfOnePluginKeyIndependently();
|
||||
testSlotKeyedSnapshotRestoresByPositionAndBoundsChecks();
|
||||
testUnresolvableIdentityNeverFallsBackToItsSlot();
|
||||
testLiveFxWithNoIdentityIsNeverARestoreTarget();
|
||||
testEmptyInputsProduceNoWrites();
|
||||
testDuplicateLiveIdentityResolvesToTheFirstSlotOnly();
|
||||
|
||||
if (g_fail == 0) std::printf("All tests passed.\n");
|
||||
return g_fail ? 1 : 0;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
// Standalone tests for reasampler::tracking::OriginLedger — no REAPER, no framework.
|
||||
//
|
||||
// The record family behind file tracking. Covers: the relative-paths-only invariant,
|
||||
// exact-string ownership, dedup, insertion order, the JSON round-trip (incl. golden
|
||||
// byte literals over every persisted enum value), the no-backfill rule, the legacy
|
||||
// path-only lift, and the Fresh / Loaded / Unreadable / FutureVersion classification
|
||||
// that keeps never-recorded apart from the two degraded states.
|
||||
// Covers: relative-paths-only, exact-string ownership, dedup, insertion order, the
|
||||
// JSON round-trip (incl. golden bytes over every persisted enum value), the append-a-
|
||||
// kind rules, the no-backfill rule, the legacy path-only lift, and the Fresh / Loaded
|
||||
// / Unreadable / FutureVersion classification.
|
||||
|
||||
#include "../src/core/tracking/origin_ledger.h"
|
||||
|
||||
@@ -162,13 +161,15 @@ static void testSerializeGoldenLiteralPinsEveryPersistedKind() {
|
||||
l.record(rec("bank/ingest.wav", OriginKind::Ingest, "S-b"));
|
||||
l.record(rec("bank/recapture.wav", OriginKind::Recapture, "S-c", "S-a"));
|
||||
l.record(rec("bank/resample.wav", OriginKind::Resample, "S-d", "S-a"));
|
||||
l.record(rec("bank/import.wav", OriginKind::PackageImport, "S-e"));
|
||||
const std::string expected =
|
||||
"{\"v\":2,\"records\":["
|
||||
"{\"path\":\"bank/unknown.wav\",\"kind\":0,\"sample\":\"\",\"parent\":\"\"},"
|
||||
"{\"path\":\"bank/capture.wav\",\"kind\":1,\"sample\":\"S-a\",\"parent\":\"\"},"
|
||||
"{\"path\":\"bank/ingest.wav\",\"kind\":2,\"sample\":\"S-b\",\"parent\":\"\"},"
|
||||
"{\"path\":\"bank/recapture.wav\",\"kind\":3,\"sample\":\"S-c\",\"parent\":\"S-a\"},"
|
||||
"{\"path\":\"bank/resample.wav\",\"kind\":4,\"sample\":\"S-d\",\"parent\":\"S-a\"}"
|
||||
"{\"path\":\"bank/resample.wav\",\"kind\":4,\"sample\":\"S-d\",\"parent\":\"S-a\"},"
|
||||
"{\"path\":\"bank/import.wav\",\"kind\":5,\"sample\":\"S-e\",\"parent\":\"\"}"
|
||||
"]}";
|
||||
CHECK(l.serialize() == expected);
|
||||
|
||||
@@ -180,6 +181,71 @@ static void testSerializeGoldenLiteralPinsEveryPersistedKind() {
|
||||
CHECK(back->find("bank/ingest.wav")->kind == OriginKind::Ingest);
|
||||
CHECK(back->find("bank/recapture.wav")->kind == OriginKind::Recapture);
|
||||
CHECK(back->find("bank/resample.wav")->kind == OriginKind::Resample);
|
||||
CHECK(back->find("bank/import.wav")->kind == OriginKind::PackageImport);
|
||||
CHECK(*back == l); // every field, not just the kind, survives the trip
|
||||
}
|
||||
|
||||
// Pins the emitted "v" byte, not the internal constant — an older build reads bytes.
|
||||
static void testAppendingAKindDoesNotMoveTheDocumentVersion() {
|
||||
OriginLedger l;
|
||||
l.record(rec("bank/import.wav", OriginKind::PackageImport, "S-e"));
|
||||
const std::string json = l.serialize();
|
||||
CHECK(json.rfind("{\"v\":2,", 0) == 0);
|
||||
CHECK(loadLedger(json).status == LedgerStatus::Loaded);
|
||||
|
||||
// The ceiling did not move with it: v3 is still a future document shape.
|
||||
CHECK(loadLedger("{\"v\":3,\"records\":[]}").status == LedgerStatus::FutureVersion);
|
||||
}
|
||||
|
||||
// Pins three unrecognized kind values (future, far-future, negative) all landing on
|
||||
// Unknown with the ledger still Loaded and the path still owned.
|
||||
static void testUnknownKindDegradesWithoutBlockingTheLedger() {
|
||||
const std::string blob =
|
||||
"{\"v\":2,\"records\":["
|
||||
"{\"path\":\"bank/next.wav\",\"kind\":6,\"sample\":\"S-1\",\"parent\":\"\"},"
|
||||
"{\"path\":\"bank/far.wav\",\"kind\":99,\"sample\":\"S-2\",\"parent\":\"\"},"
|
||||
"{\"path\":\"bank/bogus.wav\",\"kind\":-1,\"sample\":\"S-3\",\"parent\":\"\"}]}";
|
||||
|
||||
const LedgerLoad load = loadLedger(blob);
|
||||
CHECK(load.status == LedgerStatus::Loaded);
|
||||
CHECK(!ledgerDegraded(load.status));
|
||||
CHECK(load.ledger.size() == 3);
|
||||
CHECK(load.ledger.find("bank/next.wav")->kind == OriginKind::Unknown);
|
||||
CHECK(load.ledger.find("bank/far.wav")->kind == OriginKind::Unknown);
|
||||
CHECK(load.ledger.find("bank/bogus.wav")->kind == OriginKind::Unknown);
|
||||
|
||||
// Every path still owned, in order — the protection prune reads is untouched.
|
||||
const std::vector<std::string> paths = load.ledger.ownedPaths();
|
||||
CHECK(paths.size() == 3);
|
||||
CHECK(paths[0] == "bank/next.wav");
|
||||
CHECK(paths[1] == "bank/far.wav");
|
||||
CHECK(paths[2] == "bank/bogus.wav");
|
||||
|
||||
// The rest of the record survives the degrade; only the kind is lost.
|
||||
CHECK(load.ledger.find("bank/next.wav")->sampleId == "S-1");
|
||||
}
|
||||
|
||||
// ownedPaths() is the ONLY thing pruneProtection reads out of a ledger, and it is
|
||||
// kind-blind: two ledgers agreeing on paths and differing on every kind, new value
|
||||
// included, yield identical protection input. Adding a kind therefore cannot change
|
||||
// a prune decision for any existing kind.
|
||||
static void testOwnedPathsAreKindIndependent() {
|
||||
const OriginKind kinds[] = {OriginKind::Unknown, OriginKind::Capture,
|
||||
OriginKind::Ingest, OriginKind::Recapture,
|
||||
OriginKind::Resample, OriginKind::PackageImport};
|
||||
const std::size_t n = sizeof(kinds) / sizeof(kinds[0]);
|
||||
|
||||
OriginLedger baseline;
|
||||
OriginLedger varied;
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
const std::string path = "bank/f" + std::to_string(i) + ".wav";
|
||||
baseline.record(rec(path, OriginKind::Unknown, "S"));
|
||||
varied.record(rec(path, kinds[i], "S"));
|
||||
}
|
||||
|
||||
CHECK(baseline.ownedPaths() == varied.ownedPaths());
|
||||
CHECK(baseline.ownedPaths().size() == n);
|
||||
CHECK(!(baseline == varied)); // the ledgers really do differ, kind by kind
|
||||
}
|
||||
|
||||
// contains() is an EXACT-string predicate, never a prefix or substring match — the
|
||||
@@ -383,6 +449,9 @@ int main() {
|
||||
testRoundTripWithLineage();
|
||||
testRoundTripWithJsonMetacharacters();
|
||||
testSerializeGoldenLiteralPinsEveryPersistedKind();
|
||||
testAppendingAKindDoesNotMoveTheDocumentVersion();
|
||||
testUnknownKindDegradesWithoutBlockingTheLedger();
|
||||
testOwnedPathsAreKindIndependent();
|
||||
testMalformedParsesToNullopt();
|
||||
testTrailingGarbageIsRejected();
|
||||
testLegacyShapeTypeErrorsAreRejectedButEmptyIsValid();
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
// Standalone tests over the FROZEN package-compat corpus — no REAPER, no framework.
|
||||
// Nothing here builds a package: every byte comes off disk exactly as committed, which
|
||||
// is the only shape in which a later format change can be caught breaking a
|
||||
// compatibility direction. A test that re-derived its own fixture would prove only that
|
||||
// the codec agrees with itself. See tests/fixtures/package_compat/README.md.
|
||||
//
|
||||
// The values pinned below are the FIXTURE's facts, not this build's — the writer semver
|
||||
// especially must never be compared against version::stampVersion(), or a version bump
|
||||
// would silently re-anchor the corpus.
|
||||
|
||||
#include "../src/core/package/bank_package.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../src/core/package/package_format.h"
|
||||
#include "../src/core/package/package_manifest.h"
|
||||
#include "package_fixtures.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)
|
||||
|
||||
// --- what the v1 fixture's bytes say -----------------------------------------
|
||||
|
||||
static constexpr const char* kV1File = "v1_shipping.rsbank";
|
||||
static constexpr const char* kV1WriterSemver = "1.4.0";
|
||||
static constexpr const char* kV1EntryName = "kick.wav";
|
||||
static constexpr const char* kV1EntryHash = "8df36e805531e4af";
|
||||
static constexpr std::uint64_t kV1PrefixSize = 907;
|
||||
static constexpr std::uint64_t kV1PayloadLength = 300;
|
||||
static constexpr std::uint64_t kV1TotalSize = 1207;
|
||||
|
||||
// Every Sample field the shipping build wrote, with every optional PRESENT — so
|
||||
// "every known field intact" is a claim about the whole record, not a sampled few.
|
||||
// relativePath is the BARE transport name: export_plan normalizes it (see
|
||||
// src/core/package/CLAUDE.md), so a package carrying a directory component here would
|
||||
// mean the fixture predates that rule.
|
||||
static Sample expectedV1Sample() {
|
||||
Sample s;
|
||||
s.id = "cap-kick";
|
||||
s.displayName = "Kick (wet)";
|
||||
s.relativePath = "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 = 1;
|
||||
s.sampleRate = 48000;
|
||||
s.lengthSeconds = 0.0026666666666666666;
|
||||
s.lengthBeats = 0.00533;
|
||||
s.captureTempo = 120.5;
|
||||
s.captureTimeSigNum = 7;
|
||||
s.captureTimeSigDenom = 8;
|
||||
s.key = "F#m";
|
||||
s.rootNote = 60;
|
||||
s.loop = LoopPoints{8, 120};
|
||||
s.levels = {-0.3, -12.7, -14.0};
|
||||
s.clipped = true;
|
||||
s.tier = Tier::Archive;
|
||||
s.contentHash = "Wcompatcorpus0001";
|
||||
s.provenance = Provenance{"cap-parent", "fx-snapshot"};
|
||||
s.createdTimestamp = 1754000000;
|
||||
return s;
|
||||
}
|
||||
|
||||
static PackageManifest expectedV1Manifest() {
|
||||
PackageManifest m;
|
||||
m.bankDisplayName = "Compat Corpus";
|
||||
m.exportTimestamp = 1754100000;
|
||||
m.entries.push_back({kV1EntryName, kV1PayloadLength, kV1EntryHash, expectedV1Sample()});
|
||||
m.slots.append("cap-kick");
|
||||
return m;
|
||||
}
|
||||
|
||||
// A fixture that failed to open reads as an empty buffer, and an empty buffer decodes
|
||||
// Malformed — which would let half this file pass vacuously. Every suite loads through
|
||||
// here. A size mismatch is fatal to the caller (false), not just recorded: proceeding
|
||||
// with a short or empty buffer would let classifyFixture/le32At index out of range and
|
||||
// the TooNew test's hand-lifted manifest slice overread the heap, rather than fail clean.
|
||||
static bool load(const char* name, std::size_t expectedSize, std::vector<std::uint8_t>& out) {
|
||||
out = packageFixtureBytes(name);
|
||||
if (out.size() != expectedSize) {
|
||||
std::printf("FAIL: fixture %s is %zu bytes, expected %zu (path: %s)\n", name,
|
||||
out.size(), expectedSize, packageFixturePath(name).c_str());
|
||||
++g_fail;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static bool containsToken(const std::vector<std::uint8_t>& bytes, const std::string& token) {
|
||||
const std::string text(reinterpret_cast<const char*>(bytes.data()), bytes.size());
|
||||
return text.find(token) != std::string::npos;
|
||||
}
|
||||
|
||||
static std::uint32_t le32At(const std::vector<std::uint8_t>& bytes, std::size_t at) {
|
||||
return static_cast<std::uint32_t>(bytes[at]) |
|
||||
(static_cast<std::uint32_t>(bytes[at + 1]) << 8) |
|
||||
(static_cast<std::uint32_t>(bytes[at + 2]) << 16) |
|
||||
(static_cast<std::uint32_t>(bytes[at + 3]) << 24);
|
||||
}
|
||||
|
||||
// --- direction 1: the shipping build reads what it wrote ---------------------
|
||||
|
||||
// The header pair as the bytes carry it, classified without going through decode — the
|
||||
// ladder rule stated against the file rather than against the decoder's reading of it.
|
||||
static PackageReadability classifyFixture(const std::vector<std::uint8_t>& bytes) {
|
||||
return classifyPackageVersion(le32At(bytes, 4), le32At(bytes, 8));
|
||||
}
|
||||
|
||||
static void testV1FixtureDecodesToTheRecordTheShippingBuildWrote() {
|
||||
std::vector<std::uint8_t> bytes;
|
||||
if (!load(kV1File, kV1TotalSize, bytes)) return;
|
||||
CHECK(classifyFixture(bytes) == PackageReadability::Readable);
|
||||
|
||||
const DecodedPackage dec = decodePackage(bytes, bytes.size());
|
||||
CHECK(dec.status == PackageReadability::Readable);
|
||||
CHECK(dec.header.formatVersion == 1);
|
||||
CHECK(dec.header.minReaderVersion == 1);
|
||||
CHECK(dec.header.writerVersion == kV1WriterSemver);
|
||||
|
||||
CHECK(dec.manifest.entries.size() == 1);
|
||||
CHECK(dec.manifest.bankDisplayName == "Compat Corpus");
|
||||
if (dec.manifest.entries.size() == 1) {
|
||||
CHECK(dec.manifest.entries[0].fileName == kV1EntryName);
|
||||
CHECK(dec.manifest.entries[0].byteLength == kV1PayloadLength);
|
||||
CHECK(dec.manifest.entries[0].byteHash == kV1EntryHash);
|
||||
CHECK(dec.manifest.entries[0].sample == expectedV1Sample());
|
||||
}
|
||||
CHECK(dec.manifest == expectedV1Manifest());
|
||||
|
||||
CHECK(dec.prefixSize == kV1PrefixSize);
|
||||
CHECK(dec.layout.size() == 1);
|
||||
if (dec.layout.size() == 1) {
|
||||
CHECK(dec.layout[0].name == kV1EntryName);
|
||||
CHECK(dec.layout[0].offset == kV1PrefixSize);
|
||||
CHECK(dec.layout[0].length == kV1PayloadLength);
|
||||
}
|
||||
}
|
||||
|
||||
// --- direction 1: a NEWER additive writer still reads ------------------------
|
||||
|
||||
// formatVersion N+1, minReaderVersion unchanged: the whole reason two integers exist.
|
||||
// The fixture carries three keys this build has never heard of — one at the manifest
|
||||
// root, one on the entry, one inside the nested Sample blob — and must still decode to
|
||||
// exactly what the v1 fixture decodes to.
|
||||
static void testAdditiveForwardFixtureReadsWithEveryKnownFieldIntact() {
|
||||
std::vector<std::uint8_t> bytes;
|
||||
if (!load("additive_forward.rsbank", 1283, bytes)) return;
|
||||
|
||||
// Non-vacuity: the unknown keys are genuinely in the bytes, so the equality below
|
||||
// is "skipped without error", not "there was nothing to skip".
|
||||
CHECK(containsToken(bytes, "\"exportTool\""));
|
||||
CHECK(containsToken(bytes, "\"futureEntryKey\""));
|
||||
CHECK(containsToken(bytes, "\"futureSampleKey\""));
|
||||
|
||||
CHECK(classifyFixture(bytes) == PackageReadability::Readable);
|
||||
|
||||
const DecodedPackage dec = decodePackage(bytes, bytes.size());
|
||||
CHECK(dec.status == PackageReadability::Readable);
|
||||
// Literal, not kPackageFormatVersion-relative: these are the FIXTURE's frozen
|
||||
// version pair (2/1), not this build's — see the file header note.
|
||||
CHECK(dec.header.formatVersion == 2);
|
||||
CHECK(dec.header.minReaderVersion == 1);
|
||||
CHECK(dec.header.writerVersion == "1.9.0");
|
||||
|
||||
// Every known field, end to end: same manifest the v1 fixture yields.
|
||||
CHECK(dec.manifest == expectedV1Manifest());
|
||||
CHECK(dec.layout.size() == 1);
|
||||
if (dec.layout.size() == 1) CHECK(dec.layout[0].length == kV1PayloadLength);
|
||||
}
|
||||
|
||||
// --- direction 2: a structural newer writer is refused whole -----------------
|
||||
|
||||
static void testRefuseFixtureIsTooNewAndStillNamesTheWriter() {
|
||||
std::vector<std::uint8_t> bytes;
|
||||
if (!load("refuse_structural.rsbank", kV1TotalSize, bytes)) return;
|
||||
CHECK(classifyFixture(bytes) == PackageReadability::TooNew);
|
||||
|
||||
const DecodedPackage dec = decodePackage(bytes, bytes.size());
|
||||
CHECK(dec.status == PackageReadability::TooNew);
|
||||
// The three facts the refusal message owes the user. Literal, not
|
||||
// kPackageFormatVersion-relative — this is the FIXTURE's frozen pair (2/2).
|
||||
CHECK(dec.header.formatVersion == 2);
|
||||
CHECK(dec.header.minReaderVersion == 2);
|
||||
CHECK(dec.header.writerVersion == "1.9.0");
|
||||
|
||||
// Nothing else: no manifest, no layout, no partial success.
|
||||
CHECK(dec.manifest == PackageManifest{});
|
||||
CHECK(dec.layout.empty());
|
||||
CHECK(dec.prefixSize == 0);
|
||||
|
||||
// The refusal is a DECISION, not an inability: this fixture's body is the v1
|
||||
// fixture's own manifest, which parses. Walk the frozen header by hand to lift it
|
||||
// out — a reader at this version is forbidden from doing so, which is the point.
|
||||
const std::size_t manifestLenAt = 16 + le32At(bytes, 12);
|
||||
const std::uint32_t manifestLen = le32At(bytes, manifestLenAt);
|
||||
const std::string body(reinterpret_cast<const char*>(bytes.data() + manifestLenAt + 4),
|
||||
manifestLen);
|
||||
CHECK(deserializeManifest(body).has_value());
|
||||
}
|
||||
|
||||
// --- truncation: Malformed at every site, never TooNew -----------------------
|
||||
|
||||
// One fixture per DISTINCT decode failure site rather than an arithmetic spread. The
|
||||
// RSBK layout is derived from the manifest's entries, not stored as its own section, so
|
||||
// the site a "mid-layout" cut maps to is the payload boundary: the manifest parses, the
|
||||
// layout computes, and the exact-size proof is what fails.
|
||||
struct Truncation {
|
||||
const char* file;
|
||||
std::size_t size; // the cut offset — the committed file IS this many bytes
|
||||
const char* site;
|
||||
};
|
||||
|
||||
static const Truncation kTruncations[] = {
|
||||
{"trunc_magic.rsbank", 2, "inside the 4-byte magic"},
|
||||
{"trunc_version_pair.rsbank", 10, "inside the frozen header's minReaderVersion u32"},
|
||||
{"trunc_writer_semver.rsbank", 18, "inside the frozen header's writer semver"},
|
||||
{"trunc_manifest_length.rsbank", 23, "inside the manifest-length u32"},
|
||||
{"trunc_manifest_body.rsbank", 466, "inside the manifest JSON"},
|
||||
{"trunc_payload_start.rsbank", 907,
|
||||
"at the payload boundary — manifest parses, layout computes, exact-size proof fails"},
|
||||
{"trunc_payload_middle.rsbank", 1057, "inside the first payload"},
|
||||
{"trunc_one_short.rsbank", 1206, "one byte short of the total"},
|
||||
};
|
||||
|
||||
static void testEveryTruncationIsMalformedNeverTooNew() {
|
||||
for (const Truncation& t : kTruncations) {
|
||||
std::vector<std::uint8_t> bytes;
|
||||
if (!load(t.file, t.size, bytes)) continue;
|
||||
const DecodedPackage dec = decodePackage(bytes, bytes.size());
|
||||
if (dec.status != PackageReadability::Malformed) {
|
||||
std::printf("FAIL: %s (%s) classified %s, expected Malformed\n", t.file, t.site,
|
||||
dec.status == PackageReadability::TooNew ? "TooNew" : "Readable");
|
||||
++g_fail;
|
||||
}
|
||||
// A refusal never half-succeeds, at any site.
|
||||
CHECK(dec.manifest.entries.empty());
|
||||
CHECK(dec.layout.empty());
|
||||
CHECK(dec.prefixSize == 0);
|
||||
|
||||
// The shell hands decode a PREFIX plus the observed file size, not the whole
|
||||
// file — so the verdict has to survive that call shape too, at every cut the
|
||||
// incremental seam can actually satisfy.
|
||||
const auto need = requiredPrefixSize(bytes);
|
||||
if (need && *need <= bytes.size()) {
|
||||
const std::vector<std::uint8_t> head(bytes.begin(),
|
||||
bytes.begin() + static_cast<std::ptrdiff_t>(*need));
|
||||
CHECK(decodePackage(head, bytes.size()).status == PackageReadability::Malformed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- truncation: the additive-forward ladder direction, not just v1 ---------
|
||||
|
||||
// Every truncation above is a prefix of v1_shipping.rsbank (formatVersion == ours), so
|
||||
// none of them ever puts formatVersion > kPackageFormatVersion on the exact-size-proof
|
||||
// failure path — the one relabeling branch (src/core/package/CLAUDE.md: "the parse
|
||||
// branch is the ONLY one that relabels") never gets exercised from the TooNew side.
|
||||
// This fixture is additive_forward.rsbank (formatVersion 2, one past ours) cut exactly
|
||||
// at its manifest/payload boundary: the manifest parses whole, so the failure is the
|
||||
// exact-size proof, not a parse failure — it must stay Malformed, not relabel to TooNew.
|
||||
static void testAdditiveForwardTruncatedAtPayloadBoundaryStaysMalformed() {
|
||||
std::vector<std::uint8_t> bytes;
|
||||
if (!load("trunc_additive_forward.rsbank", 983, bytes)) return;
|
||||
CHECK(classifyFixture(bytes) == PackageReadability::Readable);
|
||||
|
||||
const DecodedPackage dec = decodePackage(bytes, bytes.size());
|
||||
CHECK(dec.status == PackageReadability::Malformed);
|
||||
CHECK(dec.manifest.entries.empty());
|
||||
CHECK(dec.layout.empty());
|
||||
CHECK(dec.prefixSize == 0);
|
||||
}
|
||||
|
||||
// --- hostile names: refused at decode, before any planner exists -------------
|
||||
|
||||
// The two fields carry DIFFERENT rules (src/core/package/CLAUDE.md): the entry name may
|
||||
// not express a path at all, while the nested relativePath is a path and is refused only
|
||||
// for traversal and absolute forms. Each fixture keeps the other field clean, so the
|
||||
// refusal is attributable to the field under test.
|
||||
struct HostileFixture {
|
||||
const char* file;
|
||||
std::size_t size;
|
||||
const char* offending; // the form as it reads once JSON-unescaped
|
||||
bool inEntryName; // false: the nested Sample's relativePath
|
||||
};
|
||||
|
||||
static const HostileFixture kHostiles[] = {
|
||||
{"hostile_name_dotdot.rsbank", 624, "..", true},
|
||||
{"hostile_name_parent_slash.rsbank", 633, "../evil.wav", true},
|
||||
{"hostile_name_parent_backslash.rsbank", 634, "..\\evil.wav", true},
|
||||
{"hostile_name_subdir_slash.rsbank", 634, "sub/evil.wav", true},
|
||||
{"hostile_name_drive_absolute.rsbank", 643, "C:\\Windows\\evil.wav", true},
|
||||
{"hostile_name_unc_absolute.rsbank", 646, "\\\\srv\\share\\evil.wav", true},
|
||||
{"hostile_path_dotdot_slash.rsbank", 641, "bank/../../evil.wav", false},
|
||||
{"hostile_path_dotdot_backslash.rsbank", 640, "bank\\..\\evil.wav", false},
|
||||
{"hostile_path_rooted.rsbank", 635, "/etc/evil.wav", false},
|
||||
{"hostile_path_drive_absolute.rsbank", 643, "C:\\Windows\\evil.wav", false},
|
||||
{"hostile_path_unc_absolute.rsbank", 646, "\\\\srv\\share\\evil.wav", false},
|
||||
};
|
||||
|
||||
// A backslash rides the manifest JSON doubled; the corpus table above spells the
|
||||
// unescaped form, since that is what the naming rules are asked about.
|
||||
static std::string jsonEscaped(const std::string& s) {
|
||||
std::string out;
|
||||
for (char c : s) {
|
||||
if (c == '\\') out += '\\';
|
||||
out += c;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
static void testEveryHostileNameIsRefusedAtDecode() {
|
||||
// The clean spelling both fields hold in the fixture they are NOT under test in —
|
||||
// without this, a refusal could be coming from the wrong field.
|
||||
CHECK(isValidEntryName("kick.wav"));
|
||||
CHECK(isValidNestedSamplePath("kick.wav"));
|
||||
|
||||
for (const HostileFixture& h : kHostiles) {
|
||||
std::vector<std::uint8_t> bytes;
|
||||
if (!load(h.file, h.size, bytes)) continue;
|
||||
if (!containsToken(bytes, jsonEscaped(h.offending))) {
|
||||
std::printf("FAIL: %s does not carry the form it is named for (%s)\n", h.file,
|
||||
h.offending);
|
||||
++g_fail;
|
||||
}
|
||||
|
||||
// The format's own rule on the offending field, stated directly.
|
||||
if (h.inEntryName) CHECK(!isValidEntryName(h.offending));
|
||||
else CHECK(!isValidNestedSamplePath(h.offending));
|
||||
|
||||
// Refused by the CODEC: decode yields no manifest at all, so there is nothing
|
||||
// for a planner to have been handed. planImport takes a PackageManifest, and
|
||||
// decode produced none.
|
||||
const DecodedPackage dec = decodePackage(bytes, bytes.size());
|
||||
if (dec.status != PackageReadability::Malformed) {
|
||||
std::printf("FAIL: %s classified %d, expected Malformed\n", h.file,
|
||||
static_cast<int>(dec.status));
|
||||
++g_fail;
|
||||
}
|
||||
CHECK(dec.manifest == PackageManifest{});
|
||||
CHECK(dec.layout.empty());
|
||||
CHECK(dec.prefixSize == 0);
|
||||
}
|
||||
}
|
||||
|
||||
// --- inventory: every fixture on disk is exercised by SOME test above --------
|
||||
|
||||
// This TU's own tables (the three named fixtures plus kTruncations and kHostiles) are
|
||||
// the fullest account of the corpus in the tree — every other consumer (the round-trip
|
||||
// harness, the README) tests a subset of these same files. Enumerating the fixture
|
||||
// directory here and failing on anything absent from this list is the one check that
|
||||
// catches a fixture added to disk but never wired into a table: a silent coverage drop
|
||||
// that would otherwise leave a green suite.
|
||||
static void testEveryFixtureOnDiskIsInSomeTable() {
|
||||
std::vector<std::string> known = {kV1File, "additive_forward.rsbank",
|
||||
"refuse_structural.rsbank",
|
||||
"trunc_additive_forward.rsbank"};
|
||||
for (const Truncation& t : kTruncations) known.push_back(t.file);
|
||||
for (const HostileFixture& h : kHostiles) known.push_back(h.file);
|
||||
|
||||
std::error_code ec;
|
||||
for (const auto& entry :
|
||||
std::filesystem::directory_iterator(REASAMPLER_PACKAGE_FIXTURE_DIR, ec)) {
|
||||
if (entry.path().extension() != ".rsbank") continue;
|
||||
const std::string name = entry.path().filename().string();
|
||||
if (std::find(known.begin(), known.end(), name) == known.end()) {
|
||||
std::printf("FAIL: fixture %s exists on disk but is in no table in this file\n",
|
||||
name.c_str());
|
||||
++g_fail;
|
||||
}
|
||||
}
|
||||
if (ec) {
|
||||
std::printf("FAIL: could not list fixture directory: %s\n", ec.message().c_str());
|
||||
++g_fail;
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
testV1FixtureDecodesToTheRecordTheShippingBuildWrote();
|
||||
testAdditiveForwardFixtureReadsWithEveryKnownFieldIntact();
|
||||
testRefuseFixtureIsTooNewAndStillNamesTheWriter();
|
||||
testEveryTruncationIsMalformedNeverTooNew();
|
||||
testAdditiveForwardTruncatedAtPayloadBoundaryStaysMalformed();
|
||||
testEveryHostileNameIsRefusedAtDecode();
|
||||
testEveryFixtureOnDiskIsInSomeTable();
|
||||
|
||||
if (g_fail == 0) {
|
||||
std::printf("package_compat_tests: all passed\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("package_compat_tests: %d failure(s)\n", g_fail);
|
||||
return 1;
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
// Standalone tests for reasampler::package's format contract — no REAPER, no
|
||||
// test framework. Pins the version-ladder classification (both integers, every
|
||||
// branch) and the three naming rules: the entry-name rule, the ASCII-folding
|
||||
// name equivalence, and the nested-path traversal guard.
|
||||
|
||||
#include "../src/core/package/package_format.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
|
||||
using namespace reasampler::package;
|
||||
|
||||
static int g_fail = 0;
|
||||
#define CHECK(cond) do { if(!(cond)) { \
|
||||
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
|
||||
|
||||
// --- classifyPackageVersion --------------------------------------------------
|
||||
|
||||
static void testClassifyReadable() {
|
||||
CHECK(classifyPackageVersion(kPackageFormatVersion, kPackageMinReaderVersion) ==
|
||||
PackageReadability::Readable);
|
||||
// The additive-forward-compat direction: a newer writer whose minReader
|
||||
// still reaches back to this build reads fine.
|
||||
CHECK(classifyPackageVersion(kPackageFormatVersion + 5, kPackageMinReaderVersion) ==
|
||||
PackageReadability::Readable);
|
||||
// Boundary: minReader exactly this build's format version.
|
||||
CHECK(classifyPackageVersion(kPackageFormatVersion + 1, kPackageFormatVersion) ==
|
||||
PackageReadability::Readable);
|
||||
}
|
||||
|
||||
static void testClassifyTooNew() {
|
||||
// Boundary: one past this build's format version refuses.
|
||||
CHECK(classifyPackageVersion(kPackageFormatVersion + 1, kPackageFormatVersion + 1) ==
|
||||
PackageReadability::TooNew);
|
||||
CHECK(classifyPackageVersion(99, 42) == PackageReadability::TooNew);
|
||||
}
|
||||
|
||||
static void testClassifyMalformed() {
|
||||
// Zero versions: no honest writer emits them (the ladder starts at 1).
|
||||
CHECK(classifyPackageVersion(0, 0) == PackageReadability::Malformed);
|
||||
CHECK(classifyPackageVersion(1, 0) == PackageReadability::Malformed);
|
||||
CHECK(classifyPackageVersion(0, 1) == PackageReadability::Malformed);
|
||||
// A writer cannot require a reader newer than what it wrote.
|
||||
CHECK(classifyPackageVersion(1, 2) == PackageReadability::Malformed);
|
||||
// Incoherence outranks TooNew: even with both above this build, minReader >
|
||||
// formatVersion is Malformed, not a refusal message.
|
||||
CHECK(classifyPackageVersion(5, 9) == PackageReadability::Malformed);
|
||||
}
|
||||
|
||||
// --- isValidEntryName --------------------------------------------------------
|
||||
|
||||
static void testEntryNameAccepts() {
|
||||
CHECK(isValidEntryName("kick.wav"));
|
||||
CHECK(isValidEntryName("Snare 03 (wet).wav"));
|
||||
CHECK(isValidEntryName("no-extension"));
|
||||
CHECK(isValidEntryName(".hidden")); // a leading dot is a bare name
|
||||
CHECK(isValidEntryName("a.b.c.wav")); // single dots are fine
|
||||
CHECK(isValidEntryName(std::string(kMaxEntryNameBytes, 'x'))); // at the cap
|
||||
// Legal names containing a ".." substring that is not the whole name: a
|
||||
// name can only ever be one path component (separators are banned), so
|
||||
// ".." as a component is the only expressible traversal.
|
||||
CHECK(isValidEntryName("take..final.wav"));
|
||||
CHECK(isValidEntryName("loop...wav"));
|
||||
CHECK(isValidEntryName("a..b"));
|
||||
}
|
||||
|
||||
static void testEntryNameRejectsSeparatorsAndDots() {
|
||||
CHECK(!isValidEntryName(""));
|
||||
CHECK(!isValidEntryName("."));
|
||||
CHECK(!isValidEntryName(".."));
|
||||
CHECK(!isValidEntryName("..\\evil.wav"));
|
||||
CHECK(!isValidEntryName("../evil.wav"));
|
||||
CHECK(!isValidEntryName("dir/inner.wav"));
|
||||
CHECK(!isValidEntryName("dir\\inner.wav"));
|
||||
CHECK(!isValidEntryName("/rooted.wav"));
|
||||
CHECK(!isValidEntryName("\\rooted.wav"));
|
||||
// Embedded NUL: every plausible filesystem call (ofstream, fopen,
|
||||
// CreateFileW off .c_str()) truncates at it, so two names differing only
|
||||
// after the NUL would collide on one file.
|
||||
CHECK(!isValidEntryName(std::string("a\0b.wav", 7)));
|
||||
// Other control bytes (newline here) are equally hostile to logs/UI.
|
||||
CHECK(!isValidEntryName("a\nb.wav"));
|
||||
}
|
||||
|
||||
static void testEntryNameRejectsAbsolutePrefixes() {
|
||||
CHECK(!isValidEntryName("C:\\abs.wav"));
|
||||
CHECK(!isValidEntryName("C:/abs.wav"));
|
||||
CHECK(!isValidEntryName("c:relative-to-drive.wav")); // ':' bans drive forms
|
||||
CHECK(!isValidEntryName("\\\\server\\share.wav")); // UNC
|
||||
CHECK(!isValidEntryName(std::string(kMaxEntryNameBytes + 1, 'x'))); // over cap
|
||||
}
|
||||
|
||||
static void testEntryNameRejectsWindowsHostileNames() {
|
||||
// Reserved characters.
|
||||
CHECK(!isValidEntryName("a*b.wav"));
|
||||
CHECK(!isValidEntryName("a?b.wav"));
|
||||
CHECK(!isValidEntryName("a|b.wav"));
|
||||
CHECK(!isValidEntryName("a<b>.wav"));
|
||||
CHECK(!isValidEntryName("\"q\".wav"));
|
||||
// Trailing dot or space (silently stripped at creation on Windows).
|
||||
CHECK(!isValidEntryName("trailing "));
|
||||
CHECK(!isValidEntryName("trailing."));
|
||||
CHECK(!isValidEntryName(" "));
|
||||
CHECK(!isValidEntryName(" "));
|
||||
// DOS device names, case-insensitive, with and without an extension.
|
||||
CHECK(!isValidEntryName("NUL"));
|
||||
CHECK(!isValidEntryName("CON"));
|
||||
CHECK(!isValidEntryName("con.wav"));
|
||||
CHECK(!isValidEntryName("PRN"));
|
||||
CHECK(!isValidEntryName("AUX"));
|
||||
CHECK(!isValidEntryName("COM1"));
|
||||
CHECK(!isValidEntryName("com1.txt"));
|
||||
CHECK(!isValidEntryName("LPT1"));
|
||||
// The superscript device forms (COM¹ COM² COM³ LPT¹ LPT² LPT³ in UTF-8):
|
||||
// Windows reads those as digits in a device name, so "COM².wav" is COM2.
|
||||
CHECK(!isValidEntryName("COM\xC2\xB9.wav"));
|
||||
CHECK(!isValidEntryName("com\xC2\xB2"));
|
||||
CHECK(!isValidEntryName("COM\xC2\xB3.wav"));
|
||||
CHECK(!isValidEntryName("LPT\xC2\xB9"));
|
||||
CHECK(!isValidEntryName("lpt\xC2\xB2.txt"));
|
||||
CHECK(!isValidEntryName("LPT\xC2\xB3.wav"));
|
||||
// Not a device name: a real filename that merely starts with one, and the
|
||||
// zero forms, which Windows does not reserve.
|
||||
CHECK(isValidEntryName("console.wav"));
|
||||
CHECK(isValidEntryName("COM0.wav"));
|
||||
CHECK(isValidEntryName("LPT0"));
|
||||
// Nor does a superscript past 3 name a device.
|
||||
CHECK(isValidEntryName("COM\xE2\x81\xB4.wav")); // U+2074 SUPERSCRIPT FOUR
|
||||
}
|
||||
|
||||
// --- isValidEntryName: UTF-8 well-formedness ---------------------------------
|
||||
|
||||
static void testEntryNameAcceptsWellFormedUtf8() {
|
||||
CHECK(isValidEntryName("caf\xC3\xA9.wav")); // 2-byte: é
|
||||
CHECK(isValidEntryName("\xE2\x99\xAA.wav")); // 3-byte: ♪
|
||||
CHECK(isValidEntryName("\xF0\x9F\x8E\xB5.wav")); // 4-byte: 🎵
|
||||
CHECK(isValidEntryName("\xEF\xBB\xBF.wav")); // U+FEFF, ugly but well-formed
|
||||
CHECK(isValidEntryName("\xF4\x8F\xBF\xBF.wav")); // U+10FFFF, the last code point
|
||||
}
|
||||
|
||||
static void testEntryNameRejectsIllFormedUtf8() {
|
||||
// Two names differing ONLY in their invalid bytes: a host converting to
|
||||
// UTF-16 substitutes U+FFFD for both by default, collapsing them onto one
|
||||
// file — the duplicate-name collision the manifest cannot otherwise see.
|
||||
CHECK(!isValidEntryName("a\x80.wav")); // stray continuation byte
|
||||
CHECK(!isValidEntryName("a\x81.wav"));
|
||||
// Structural: truncated sequences (a lead byte the name ends inside).
|
||||
CHECK(!isValidEntryName("a\xC3"));
|
||||
CHECK(!isValidEntryName("a\xE2\x99"));
|
||||
CHECK(!isValidEntryName("a\xF0\x9F\x8E"));
|
||||
// A lead byte followed by a non-continuation.
|
||||
CHECK(!isValidEntryName("a\xC3\x41.wav"));
|
||||
// Overlong encodings: an alternate spelling of an ASCII byte we ban.
|
||||
CHECK(!isValidEntryName("a\xC0\xAF.wav")); // overlong '/'
|
||||
CHECK(!isValidEntryName("a\xC0\x80.wav")); // overlong NUL
|
||||
CHECK(!isValidEntryName("a\xE0\x80\xAF.wav")); // overlong '/', 3-byte
|
||||
CHECK(!isValidEntryName("a\xF0\x80\x80\xAF.wav")); // overlong '/', 4-byte
|
||||
// Surrogate halves: no code point, and unrepresentable in UTF-16.
|
||||
CHECK(!isValidEntryName("a\xED\xA0\x80.wav")); // U+D800
|
||||
CHECK(!isValidEntryName("a\xED\xBF\xBF.wav")); // U+DFFF
|
||||
// Past U+10FFFF, and the 5/6-byte leads that never encode anything.
|
||||
CHECK(!isValidEntryName("a\xF4\x90\x80\x80.wav")); // U+110000
|
||||
CHECK(!isValidEntryName("a\xF5\x80\x80\x80.wav"));
|
||||
CHECK(!isValidEntryName("a\xFC\x80\x80\x80\x80\x80.wav"));
|
||||
CHECK(!isValidEntryName("a\xFF.wav"));
|
||||
}
|
||||
|
||||
// --- sameEntryName -----------------------------------------------------------
|
||||
|
||||
static void testSameEntryNameFoldsAsciiCase() {
|
||||
// A bank authored on a case-sensitive filesystem produces this pair
|
||||
// honestly; Windows and default APFS would extract both onto one file.
|
||||
CHECK(sameEntryName("Kick.wav", "kick.wav"));
|
||||
CHECK(sameEntryName("KICK.WAV", "kick.wav"));
|
||||
CHECK(sameEntryName("kick.wav", "kick.wav"));
|
||||
CHECK(!sameEntryName("kick.wav", "snare.wav"));
|
||||
CHECK(!sameEntryName("kick.wav", "kick.wave")); // length alone decides
|
||||
CHECK(!sameEntryName("", "a"));
|
||||
CHECK(sameEntryName("", ""));
|
||||
// ASCII only: "é" vs "É" are two names here (the NFC/NFD limitation this
|
||||
// shares — see this directory's CLAUDE.md).
|
||||
CHECK(!sameEntryName("caf\xC3\xA9.wav", "caf\xC3\x89.wav"));
|
||||
// Only the letters fold — the bytes flanking the ASCII range must not.
|
||||
CHECK(!sameEntryName("a[b", "a{b")); // 0x5B vs 0x7B, 'Z'+1 and 'z'+1
|
||||
CHECK(!sameEntryName("a@b", "a`b")); // 0x40 vs 0x60, 'A'-1 and 'a'-1
|
||||
}
|
||||
|
||||
// --- isValidNestedSamplePath -------------------------------------------------
|
||||
|
||||
static void testNestedSamplePathAcceptsRelative() {
|
||||
CHECK(isValidNestedSamplePath("a.wav"));
|
||||
CHECK(isValidNestedSamplePath("reasampler_bank/kick.wav"));
|
||||
CHECK(isValidNestedSamplePath("reasampler_bank\\kick.wav"));
|
||||
CHECK(isValidNestedSamplePath("deep/dir/tree/a.wav"));
|
||||
// A ".." that is not a whole component is an ordinary name.
|
||||
CHECK(isValidNestedSamplePath("take..final/a.wav"));
|
||||
CHECK(isValidNestedSamplePath("bank/..hidden"));
|
||||
CHECK(isValidNestedSamplePath("a..b"));
|
||||
}
|
||||
|
||||
static void testNestedSamplePathRejectsTraversalAndAbsolute() {
|
||||
// BankModel::add catches only the absolute forms, so traversal reaches the
|
||||
// format unless this rule stops it.
|
||||
CHECK(!isValidNestedSamplePath(".."));
|
||||
CHECK(!isValidNestedSamplePath("../evil.wav"));
|
||||
CHECK(!isValidNestedSamplePath("..\\evil.wav"));
|
||||
CHECK(!isValidNestedSamplePath("bank/../../evil.wav"));
|
||||
CHECK(!isValidNestedSamplePath("bank\\..\\evil.wav"));
|
||||
CHECK(!isValidNestedSamplePath("bank/.."));
|
||||
CHECK(!isValidNestedSamplePath("bank/../"));
|
||||
// Everything util::isAbsolutePath already catches.
|
||||
CHECK(!isValidNestedSamplePath("/rooted.wav"));
|
||||
CHECK(!isValidNestedSamplePath("\\rooted.wav"));
|
||||
CHECK(!isValidNestedSamplePath("C:/abs.wav"));
|
||||
CHECK(!isValidNestedSamplePath("C:\\abs.wav"));
|
||||
CHECK(!isValidNestedSamplePath("c:relative-to-drive.wav"));
|
||||
CHECK(!isValidNestedSamplePath("\\\\server\\share.wav"));
|
||||
}
|
||||
|
||||
int main() {
|
||||
testClassifyReadable();
|
||||
testClassifyTooNew();
|
||||
testClassifyMalformed();
|
||||
testEntryNameAccepts();
|
||||
testEntryNameRejectsSeparatorsAndDots();
|
||||
testEntryNameRejectsAbsolutePrefixes();
|
||||
testEntryNameRejectsWindowsHostileNames();
|
||||
testEntryNameAcceptsWellFormedUtf8();
|
||||
testEntryNameRejectsIllFormedUtf8();
|
||||
testSameEntryNameFoldsAsciiCase();
|
||||
testNestedSamplePathAcceptsRelative();
|
||||
testNestedSamplePathRejectsTraversalAndAbsolute();
|
||||
|
||||
if (g_fail == 0) {
|
||||
std::printf("package_format_tests: all passed\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("package_format_tests: %d failure(s)\n", g_fail);
|
||||
return 1;
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
// Standalone tests for shell/package/package_io — no REAPER, no framework. Failure is
|
||||
// injected at the writer seam (abandonment, open failure, rename failure) rather than
|
||||
// simulated, and the streaming claim is asserted against PayloadBuffer::alive().
|
||||
|
||||
#include "../src/shell/package/package_io.h"
|
||||
#include "../src/shell/package/package_path.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
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)
|
||||
|
||||
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 bool sameBytes(const PayloadBuffer& p, const std::vector<std::uint8_t>& want) {
|
||||
return p.size() == want.size() &&
|
||||
(want.empty() || std::equal(want.begin(), want.end(), p.data()));
|
||||
}
|
||||
|
||||
static void writeScratchFile(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> readAll(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)); }
|
||||
|
||||
static void removeQuietly(const std::string& path) {
|
||||
std::error_code ec;
|
||||
fs::remove(utf8Path(path), ec);
|
||||
}
|
||||
|
||||
static void testPayloadCounterTracksMovesNotCopies() {
|
||||
CHECK(PayloadBuffer::alive() == 0);
|
||||
{
|
||||
PayloadBuffer a(patternBytes(4, 1));
|
||||
CHECK(PayloadBuffer::alive() == 1);
|
||||
PayloadBuffer b = std::move(a);
|
||||
CHECK(PayloadBuffer::alive() == 1); // the count moved with the bytes
|
||||
PayloadBuffer c;
|
||||
c = std::move(b);
|
||||
CHECK(PayloadBuffer::alive() == 1);
|
||||
CHECK(c.size() == 4);
|
||||
}
|
||||
CHECK(PayloadBuffer::alive() == 0);
|
||||
{
|
||||
PayloadBuffer empty;
|
||||
CHECK(PayloadBuffer::alive() == 0); // holding nothing counts as nothing
|
||||
}
|
||||
}
|
||||
|
||||
static void testStreamingRoundTripHoldsOnePayload() {
|
||||
std::error_code destEc;
|
||||
const std::string dest =
|
||||
(fs::temp_directory_path(destEc) / "pkg_io_scratch.rsbank").generic_string();
|
||||
const std::vector<std::uint8_t> header = patternBytes(16, 0xA0);
|
||||
const std::vector<std::vector<std::uint8_t>> entries = {
|
||||
patternBytes(1000, 1), patternBytes(500, 2), patternBytes(1, 3)};
|
||||
|
||||
std::vector<std::string> srcs;
|
||||
for (std::size_t i = 0; i < entries.size(); ++i) {
|
||||
srcs.push_back("pkg_io_src" + std::to_string(i) + ".bin");
|
||||
writeScratchFile(srcs[i], entries[i]);
|
||||
}
|
||||
|
||||
CHECK(PayloadBuffer::alive() == 0);
|
||||
std::vector<std::pair<std::uint64_t, std::uint64_t>> layout; // offset, length
|
||||
{
|
||||
PackageFileWriter writer(dest);
|
||||
CHECK(writer.ok());
|
||||
CHECK(writer.appendRaw(header.data(), header.size()));
|
||||
std::uint64_t offset = header.size();
|
||||
for (std::size_t i = 0; i < entries.size(); ++i) {
|
||||
PayloadBuffer p = readFilePayload(srcs[i]);
|
||||
CHECK(sameBytes(p, entries[i]));
|
||||
CHECK(PayloadBuffer::alive() == 1); // exactly one entry in memory
|
||||
CHECK(writer.appendPayload(p));
|
||||
layout.emplace_back(offset, p.size());
|
||||
offset += p.size();
|
||||
}
|
||||
CHECK(PayloadBuffer::alive() == 0); // each released before the next
|
||||
CHECK(writer.commit());
|
||||
CHECK(!writer.commit()); // a second commit is refused
|
||||
}
|
||||
CHECK(!exists(dest + ".rsbanktmp"));
|
||||
CHECK(exists(dest));
|
||||
|
||||
{
|
||||
// Scoped: the reader holds the file open, and Windows refuses to delete an
|
||||
// open file — cleanup below needs it closed first.
|
||||
PackageFileReader reader(dest);
|
||||
CHECK(reader.ok());
|
||||
CHECK(reader.fileSize() == header.size() + 1000 + 500 + 1);
|
||||
for (std::size_t i = 0; i < entries.size(); ++i) {
|
||||
PayloadBuffer p = reader.readRange(layout[i].first, layout[i].second);
|
||||
CHECK(PayloadBuffer::alive() == 1); // one entry per readRange, no more
|
||||
CHECK(sameBytes(p, entries[i]));
|
||||
}
|
||||
CHECK(PayloadBuffer::alive() == 0);
|
||||
}
|
||||
|
||||
for (const std::string& s : srcs) removeQuietly(s);
|
||||
removeQuietly(dest);
|
||||
}
|
||||
|
||||
static void testEmptyPayloadIsRefusedAndPoisonsTheWriter() {
|
||||
// The failure this guards: an unreadable source yields an empty payload, and a
|
||||
// verb that trusted a `true` here would commit framing claiming bytes nobody wrote.
|
||||
const std::string dest = "pkg_io_emptypayload.rsbank";
|
||||
{
|
||||
PackageFileWriter writer(dest);
|
||||
const std::vector<std::uint8_t> head = patternBytes(4, 1);
|
||||
CHECK(writer.appendRaw(head.data(), head.size()));
|
||||
CHECK(!writer.appendPayload(PayloadBuffer{}));
|
||||
CHECK(!writer.ok());
|
||||
CHECK(!writer.commit()); // the short stream can never reach the destination
|
||||
}
|
||||
CHECK(!exists(dest));
|
||||
CHECK(!exists(dest + ".rsbanktmp"));
|
||||
// A zero-length appendRaw stays tolerated — framing has legitimate empty edges.
|
||||
{
|
||||
PackageFileWriter writer(dest);
|
||||
CHECK(writer.appendRaw(nullptr, 0));
|
||||
CHECK(writer.ok());
|
||||
writer.abort();
|
||||
}
|
||||
}
|
||||
|
||||
static void testAbandonedWriteLeavesNoDestination() {
|
||||
const std::string dest = "pkg_io_abandon.rsbank";
|
||||
{
|
||||
PackageFileWriter writer(dest);
|
||||
const std::vector<std::uint8_t> some = patternBytes(64, 9);
|
||||
CHECK(writer.appendRaw(some.data(), some.size()));
|
||||
// no commit — destruction is the injected interruption
|
||||
}
|
||||
CHECK(!exists(dest));
|
||||
CHECK(!exists(dest + ".rsbanktmp"));
|
||||
}
|
||||
|
||||
static void testAbortPreservesPriorContents() {
|
||||
const std::string dest = "pkg_io_prior.rsbank";
|
||||
const std::vector<std::uint8_t> prior = patternBytes(32, 0x40);
|
||||
writeScratchFile(dest, prior);
|
||||
{
|
||||
PackageFileWriter writer(dest);
|
||||
const std::vector<std::uint8_t> some = patternBytes(64, 9);
|
||||
CHECK(writer.appendRaw(some.data(), some.size()));
|
||||
writer.abort();
|
||||
CHECK(!writer.appendRaw(some.data(), some.size())); // dead after abort
|
||||
CHECK(!writer.commit());
|
||||
}
|
||||
CHECK(readAll(dest) == prior);
|
||||
CHECK(!exists(dest + ".rsbanktmp"));
|
||||
removeQuietly(dest);
|
||||
}
|
||||
|
||||
static void testCommitReplacesAnExistingFile() {
|
||||
// The module's one deliberate asymmetry against LandedFileJournal's never-overwrite
|
||||
// rule: the save dialog's overwrite confirm is the consent, so commit() replaces.
|
||||
const std::string dest = "pkg_io_replace.rsbank";
|
||||
const std::vector<std::uint8_t> prior = patternBytes(40, 0x11);
|
||||
const std::vector<std::uint8_t> fresh = patternBytes(7, 0x22);
|
||||
writeScratchFile(dest, prior);
|
||||
{
|
||||
PackageFileWriter writer(dest);
|
||||
CHECK(writer.ok());
|
||||
CHECK(writer.appendRaw(fresh.data(), fresh.size()));
|
||||
CHECK(writer.commit());
|
||||
}
|
||||
CHECK(readAll(dest) == fresh);
|
||||
CHECK(!exists(dest + ".rsbanktmp"));
|
||||
removeQuietly(dest);
|
||||
}
|
||||
|
||||
static void testOpenFailureIsInert() {
|
||||
const std::string dest = "pkg_io_no_such_dir/x.rsbank";
|
||||
PackageFileWriter writer(dest);
|
||||
CHECK(!writer.ok());
|
||||
const std::vector<std::uint8_t> some = patternBytes(8, 1);
|
||||
CHECK(!writer.appendRaw(some.data(), some.size()));
|
||||
CHECK(!writer.commit());
|
||||
CHECK(!exists("pkg_io_no_such_dir"));
|
||||
}
|
||||
|
||||
static void testCommitRenameFailureSelfCleans() {
|
||||
// A directory squatting on the destination makes the final rename fail — a real
|
||||
// injected commit failure, not a simulated one.
|
||||
const std::string dest = "pkg_io_dir.rsbank";
|
||||
std::error_code ec;
|
||||
fs::create_directory(utf8Path(dest), ec);
|
||||
CHECK(!ec);
|
||||
{
|
||||
PackageFileWriter writer(dest);
|
||||
CHECK(writer.ok());
|
||||
const std::vector<std::uint8_t> some = patternBytes(8, 1);
|
||||
CHECK(writer.appendRaw(some.data(), some.size()));
|
||||
CHECK(!writer.commit());
|
||||
}
|
||||
CHECK(fs::is_directory(utf8Path(dest))); // prior state intact
|
||||
CHECK(!exists(dest + ".rsbanktmp"));
|
||||
fs::remove(utf8Path(dest), ec);
|
||||
}
|
||||
|
||||
static void testReaderEdges() {
|
||||
PackageFileReader missing("pkg_io_no_such_file.rsbank");
|
||||
CHECK(!missing.ok());
|
||||
CHECK(missing.readRange(0, 1).empty());
|
||||
|
||||
const std::string path = "pkg_io_edges.bin";
|
||||
const std::vector<std::uint8_t> bytes = patternBytes(10, 5);
|
||||
writeScratchFile(path, bytes);
|
||||
{
|
||||
// Scoped: the reader must be closed before the cleanup remove below.
|
||||
PackageFileReader reader(path);
|
||||
CHECK(reader.ok());
|
||||
CHECK(reader.fileSize() == 10);
|
||||
CHECK(reader.readRange(5, 10).empty()); // past the end
|
||||
CHECK(reader.readRange(10, 1).empty()); // starts at the end
|
||||
CHECK(reader.readRange(0, 0).empty()); // zero length is failure, one branch
|
||||
CHECK(sameBytes(reader.readRange(2, 3),
|
||||
std::vector<std::uint8_t>(bytes.begin() + 2, bytes.begin() + 5)));
|
||||
}
|
||||
removeQuietly(path);
|
||||
}
|
||||
|
||||
static void testFileStatusSeparatesAbsentFromUnreadable() {
|
||||
CHECK(fileStatus("pkg_io_no_such_file.bin") == FileStatus::Absent);
|
||||
|
||||
const std::string path = "pkg_io_status.bin";
|
||||
writeScratchFile(path, patternBytes(4, 1));
|
||||
CHECK(fileStatus(path) == FileStatus::Present);
|
||||
removeQuietly(path);
|
||||
|
||||
// A directory in a file's place is not a readable file — the export message must
|
||||
// not report it as simply missing.
|
||||
const std::string dir = "pkg_io_status_dir";
|
||||
std::error_code ec;
|
||||
fs::create_directory(utf8Path(dir), ec);
|
||||
CHECK(fileStatus(dir) == FileStatus::Unreadable);
|
||||
fs::remove(utf8Path(dir), ec);
|
||||
}
|
||||
|
||||
static void testNonAsciiPathsRoundTripAsUtf8() {
|
||||
// "café" in UTF-8. Windows decodes a narrow std::filesystem path through the ANSI
|
||||
// code page, so an unconverted seam lands "café" or fails outright.
|
||||
const std::string dir = "pkg_io_caf\xC3\xA9_dir";
|
||||
const std::string name = "caf\xC3\xA9.rsbank";
|
||||
std::error_code ec;
|
||||
fs::create_directory(utf8Path(dir), ec);
|
||||
CHECK(!ec);
|
||||
|
||||
const std::string dest = dir + "/" + name;
|
||||
const std::vector<std::uint8_t> bytes = patternBytes(24, 0x5A);
|
||||
{
|
||||
PackageFileWriter writer(dest);
|
||||
CHECK(writer.ok());
|
||||
CHECK(writer.appendRaw(bytes.data(), bytes.size()));
|
||||
CHECK(writer.commit());
|
||||
}
|
||||
CHECK(exists(dest));
|
||||
CHECK(fileStatus(dest) == FileStatus::Present);
|
||||
CHECK(sameBytes(readFilePayload(dest), bytes));
|
||||
// The listing must hand the name back in the same encoding it was given.
|
||||
CHECK(listFolderFileNames(dir) == (std::vector<std::string>{name}));
|
||||
|
||||
fs::remove_all(utf8Path(dir), ec);
|
||||
}
|
||||
|
||||
static void testWriteFileExclusiveRefusesAnOccupiedPath() {
|
||||
const std::string path = "pkg_io_excl.bin";
|
||||
const std::vector<std::uint8_t> mine = patternBytes(12, 3);
|
||||
CHECK(writeFileExclusive(path, PayloadBuffer(mine)));
|
||||
CHECK(readAll(path) == mine);
|
||||
// The create IS the check: a second call cannot replace the first file's bytes.
|
||||
// (this pins the refusal, not O_EXCL's atomicity — a genuine race isn't portable)
|
||||
CHECK(!writeFileExclusive(path, PayloadBuffer(patternBytes(9, 8))));
|
||||
CHECK(readAll(path) == mine);
|
||||
CHECK(!writeFileExclusive("pkg_io_excl_empty.bin", PayloadBuffer{}));
|
||||
CHECK(!exists("pkg_io_excl_empty.bin"));
|
||||
removeQuietly(path);
|
||||
|
||||
// writeFileExclusive is the one call site using the wstring()/c_str() Windows
|
||||
// open form rather than the fstream(fs::path) overload every other test here
|
||||
// exercises — the only conversion whose reversion this file would otherwise miss.
|
||||
const std::string cafePath = "pkg_io_excl_caf\xC3\xA9.bin";
|
||||
const std::vector<std::uint8_t> cafeBytes = patternBytes(6, 4);
|
||||
CHECK(writeFileExclusive(cafePath, PayloadBuffer(cafeBytes)));
|
||||
CHECK(readAll(cafePath) == cafeBytes);
|
||||
CHECK(!writeFileExclusive(cafePath, PayloadBuffer(patternBytes(3, 9))));
|
||||
removeQuietly(cafePath);
|
||||
}
|
||||
|
||||
static void testListFolderFileNames() {
|
||||
const std::string dir = "pkg_io_listdir";
|
||||
std::error_code ec;
|
||||
fs::create_directory(utf8Path(dir), ec);
|
||||
writeScratchFile(dir + "/b.bin", patternBytes(2, 1));
|
||||
writeScratchFile(dir + "/a.bin", patternBytes(2, 2));
|
||||
fs::create_directory(utf8Path(dir + "/sub"), ec);
|
||||
writeScratchFile(dir + "/sub/c.bin", patternBytes(2, 3));
|
||||
|
||||
const std::vector<std::string> names = listFolderFileNames(dir);
|
||||
CHECK(names == (std::vector<std::string>{"a.bin", "b.bin"})); // sorted, bare, non-recursive
|
||||
CHECK(listFolderFileNames("pkg_io_no_such_dir").empty());
|
||||
|
||||
fs::remove_all(utf8Path(dir), ec);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testPayloadCounterTracksMovesNotCopies();
|
||||
testStreamingRoundTripHoldsOnePayload();
|
||||
testEmptyPayloadIsRefusedAndPoisonsTheWriter();
|
||||
testAbandonedWriteLeavesNoDestination();
|
||||
testAbortPreservesPriorContents();
|
||||
testCommitReplacesAnExistingFile();
|
||||
testOpenFailureIsInert();
|
||||
testCommitRenameFailureSelfCleans();
|
||||
testReaderEdges();
|
||||
testFileStatusSeparatesAbsentFromUnreadable();
|
||||
testNonAsciiPathsRoundTripAsUtf8();
|
||||
testWriteFileExclusiveRefusesAnOccupiedPath();
|
||||
testListFolderFileNames();
|
||||
|
||||
if (g_fail == 0) std::printf("package_io: all tests passed\n");
|
||||
else std::printf("package_io: %d CHECK(s) FAILED\n", g_fail);
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
// 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 naming, case-folding and traversal rules 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());
|
||||
}
|
||||
}
|
||||
|
||||
// One entry, `name` spliced in as raw manifest text so a hostile spelling
|
||||
// (escapes included) is expressible — a package is not limited to what encode
|
||||
// emits.
|
||||
static std::string oneEntryJson(const std::string& name,
|
||||
const std::string& relativePath = "bank/a.wav") {
|
||||
return "{\"entries\":[{\"name\":\"" + name +
|
||||
"\",\"length\":1,\"hash\":\"h\","
|
||||
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\","
|
||||
"\"relativePath\":\"" + relativePath + "\"}]}}]}";
|
||||
}
|
||||
|
||||
static void testDecodeRejectsBadEntryName() {
|
||||
for (const char* bad : {"..\\\\evil.wav", "../evil.wav", "dir/a.wav", "C:\\\\a.wav", ".."})
|
||||
CHECK(!deserializeManifest(oneEntryJson(bad)).has_value());
|
||||
|
||||
// The rest of the rule set through decode — the direction that matters,
|
||||
// since a package can arrive from anywhere.
|
||||
CHECK(!deserializeManifest(oneEntryJson("CON.wav")).has_value()); // DOS device
|
||||
CHECK(!deserializeManifest(oneEntryJson("COM\xC2\xB2.wav")).has_value()); // COM²
|
||||
CHECK(!deserializeManifest(oneEntryJson("a.wav ")).has_value()); // trailing space
|
||||
CHECK(!deserializeManifest(oneEntryJson("a.wav.")).has_value()); // trailing dot
|
||||
CHECK(!deserializeManifest(oneEntryJson("a*b.wav")).has_value()); // reserved char
|
||||
// A NUL smuggled in as a JSON escape: the manifest text is legal, the
|
||||
// decoded name is not.
|
||||
CHECK(!deserializeManifest(oneEntryJson("a\\u0000b.wav")).has_value());
|
||||
// Ill-formed UTF-8 as raw bytes.
|
||||
CHECK(!deserializeManifest(oneEntryJson("a\xC3.wav")).has_value());
|
||||
// A lone surrogate never reaches the name rule — the JSON reader refuses
|
||||
// the unpaired \uD800 first. Pinned so that refusal cannot silently become
|
||||
// "decoded to U+FFFD and accepted".
|
||||
CHECK(!deserializeManifest(oneEntryJson("a\\ud800b.wav")).has_value());
|
||||
}
|
||||
|
||||
static void testDecodeRejectsTraversalInNestedPath() {
|
||||
// The one field in the format that CAN express a path. BankModel::add
|
||||
// catches the absolute forms only, so ".." arrives unless the package layer
|
||||
// refuses it.
|
||||
CHECK(!deserializeManifest(oneEntryJson("a.wav", "../../evil.wav")).has_value());
|
||||
CHECK(!deserializeManifest(oneEntryJson("a.wav", "bank/../evil.wav")).has_value());
|
||||
CHECK(!deserializeManifest(oneEntryJson("a.wav", "..")).has_value());
|
||||
// A ".." that is not a whole component still reads.
|
||||
CHECK(deserializeManifest(oneEntryJson("a.wav", "take..final/a.wav")).has_value());
|
||||
}
|
||||
|
||||
static void testEncodeRejectsTraversalInNestedPath() {
|
||||
PackageManifest m = fixture();
|
||||
m.entries[0].sample.relativePath = "../../evil.wav";
|
||||
CHECK(!serializeManifest(m).has_value());
|
||||
|
||||
PackageManifest m2 = fixture();
|
||||
m2.entries[0].sample.relativePath = "bank/../evil.wav";
|
||||
CHECK(!serializeManifest(m2).has_value());
|
||||
}
|
||||
|
||||
static void testDuplicateEntryNamesRejectedBothWays() {
|
||||
PackageManifest m = fixture();
|
||||
m.entries[1].fileName = m.entries[0].fileName;
|
||||
CHECK(!serializeManifest(m).has_value());
|
||||
|
||||
// Case-folded: two names one case-insensitive filesystem extracts onto a
|
||||
// single file are one name here too, in both directions.
|
||||
PackageManifest folded = fixture();
|
||||
folded.entries[1].fileName = "KICK.WAV"; // entries[0] is "kick.wav"
|
||||
CHECK(!serializeManifest(folded).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());
|
||||
|
||||
// Two names that differ outside the ASCII letters are still two names.
|
||||
const std::string distinct =
|
||||
"{\"entries\":["
|
||||
"{\"name\":\"a1.wav\",\"length\":1,\"hash\":\"h\","
|
||||
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\",\"relativePath\":\"p\"}]}},"
|
||||
"{\"name\":\"a2.wav\",\"length\":2,\"hash\":\"i\","
|
||||
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s2\",\"relativePath\":\"q\"}]}}]}";
|
||||
CHECK(deserializeManifest(distinct).has_value());
|
||||
}
|
||||
|
||||
static void testRepeatedRootKeyRejected() {
|
||||
// A repeated "entries" must not accumulate into two arrays' worth of
|
||||
// entries; the other three assign rather than append, but "which duplicate
|
||||
// keys are legal" is one format answer, not four.
|
||||
const std::string entriesTwice =
|
||||
"{\"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(entriesTwice).has_value());
|
||||
|
||||
CHECK(!deserializeManifest("{\"bankName\":\"A\",\"bankName\":\"B\"}").has_value());
|
||||
CHECK(!deserializeManifest("{\"exported\":1,\"exported\":2}").has_value());
|
||||
CHECK(!deserializeManifest("{\"slots\":[],\"slots\":[]}").has_value());
|
||||
// Unknown keys stay repeatable: they are skipped, and a future format must
|
||||
// be free to add them.
|
||||
CHECK(deserializeManifest("{\"future\":1,\"future\":2}").has_value());
|
||||
|
||||
// The same answer one level down, so a hostile manifest cannot make two
|
||||
// readers disagree about which spelling of an entry field is the real one.
|
||||
CHECK(!deserializeManifest(
|
||||
"{\"entries\":[{\"name\":\"a.wav\",\"name\":\"b.wav\",\"length\":1,\"hash\":\"h\","
|
||||
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\",\"relativePath\":\"p\"}]}}]}")
|
||||
.has_value());
|
||||
CHECK(!deserializeManifest(
|
||||
"{\"entries\":[{\"name\":\"a.wav\",\"length\":1,\"length\":2,\"hash\":\"h\","
|
||||
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\",\"relativePath\":\"p\"}]}}]}")
|
||||
.has_value());
|
||||
CHECK(!deserializeManifest(
|
||||
"{\"entries\":[{\"name\":\"a.wav\",\"length\":1,\"hash\":\"h\",\"hash\":\"i\","
|
||||
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\",\"relativePath\":\"p\"}]}}]}")
|
||||
.has_value());
|
||||
CHECK(!deserializeManifest(
|
||||
"{\"entries\":[{\"name\":\"a.wav\",\"length\":1,\"hash\":\"h\","
|
||||
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\",\"relativePath\":\"p\"}]},"
|
||||
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s2\",\"relativePath\":\"q\"}]}}]}")
|
||||
.has_value());
|
||||
}
|
||||
|
||||
// --- rejection: structural ---------------------------------------------------
|
||||
|
||||
// See src/core/package/CLAUDE.md for the shell seam that forces this.
|
||||
static void testEncodeRejectsZeroLengthEntry() {
|
||||
PackageManifest m = fixture();
|
||||
m.entries[0].byteLength = 0;
|
||||
CHECK(!serializeManifest(m).has_value());
|
||||
}
|
||||
|
||||
// The asymmetry is deliberate: refusing a zero-length entry is an obligation on
|
||||
// what this layer WRITES, not a claim about what a package may declare. Pinned
|
||||
// so it is not "fixed" into a decode-side rejection.
|
||||
static void testDecodeAcceptsZeroLengthEntry() {
|
||||
auto m = deserializeManifest(
|
||||
"{\"entries\":[{\"name\":\"a.wav\",\"length\":0,\"hash\":\"h\","
|
||||
"\"index\":{\"version\":1,\"samples\":[{\"id\":\"s1\",\"relativePath\":\"p\"}]}}]}");
|
||||
CHECK(m.has_value());
|
||||
CHECK(m->entries.size() == 1);
|
||||
CHECK(m->entries[0].byteLength == 0);
|
||||
}
|
||||
|
||||
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();
|
||||
testDecodeRejectsTraversalInNestedPath();
|
||||
testEncodeRejectsTraversalInNestedPath();
|
||||
testDuplicateEntryNamesRejectedBothWays();
|
||||
testRepeatedRootKeyRejected();
|
||||
testEncodeRejectsZeroLengthEntry();
|
||||
testDecodeAcceptsZeroLengthEntry();
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
// Standalone tests for shell/package/package_rollback — no REAPER, no framework.
|
||||
// Pins both halves of the deletion discriminator: the structural half (only an
|
||||
// exclusively-created path is recorded, and the record is absolute so a CWD change
|
||||
// cannot re-aim it) and the contract half (markIndexCommitted disarms rollback).
|
||||
|
||||
#include "../src/shell/package/package_rollback.h"
|
||||
#include "../src/shell/package/package_path.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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)
|
||||
|
||||
// The journal records absolute paths, so every expectation is built the same way.
|
||||
static std::string scratch(const std::string& name) {
|
||||
return pathToUtf8(fs::current_path() / utf8Path(name));
|
||||
}
|
||||
|
||||
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 writeScratchFile(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> readAll(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)); }
|
||||
|
||||
static void removeQuietly(const std::string& path) {
|
||||
std::error_code ec;
|
||||
fs::remove(utf8Path(path), ec);
|
||||
}
|
||||
|
||||
static void testLandRecordsOnSuccessOnly() {
|
||||
LandedFileJournal journal;
|
||||
const std::string path = scratch("rb_land.bin");
|
||||
const std::vector<std::uint8_t> bytes = patternBytes(32, 1);
|
||||
CHECK(journal.writeLandedFile(path, PayloadBuffer(bytes)));
|
||||
CHECK(readAll(path) == bytes);
|
||||
CHECK(journal.landedPaths().size() == 1);
|
||||
CHECK(!exists(path + ".rsbanktmp")); // the land is a direct exclusive create
|
||||
journal.rollback();
|
||||
CHECK(!exists(path)); // the recorded path denoted the file we asked for
|
||||
}
|
||||
|
||||
static void testLandNonAsciiPathRoundTripsAsUtf8() {
|
||||
// The fs::absolute -> u8string round trip at writeLandedFile is otherwise
|
||||
// untested with a non-ASCII path.
|
||||
LandedFileJournal journal;
|
||||
const std::string path = scratch("rb_caf\xC3\xA9.bin");
|
||||
const std::vector<std::uint8_t> bytes = patternBytes(16, 2);
|
||||
CHECK(journal.writeLandedFile(path, PayloadBuffer(bytes)));
|
||||
CHECK(readAll(path) == bytes);
|
||||
journal.rollback();
|
||||
CHECK(!exists(path));
|
||||
}
|
||||
|
||||
static void testRelativeInputIsRecordedAbsolute() {
|
||||
// The hazard: a bare name recorded verbatim, then a CWD change, and rollback
|
||||
// unlinks whatever now sits at that name in the new directory.
|
||||
LandedFileJournal journal;
|
||||
CHECK(journal.writeLandedFile("rb_relative.bin", PayloadBuffer(patternBytes(8, 4))));
|
||||
CHECK(journal.landedPaths().size() == 1);
|
||||
const std::string recorded = journal.landedPaths().front();
|
||||
CHECK(utf8Path(recorded).is_absolute());
|
||||
std::error_code ec;
|
||||
// Absolute AND still the same file — a spelling check alone would not prove that.
|
||||
CHECK(fs::equivalent(utf8Path(recorded), utf8Path(scratch("rb_relative.bin")), ec));
|
||||
CHECK(!ec);
|
||||
journal.rollback();
|
||||
CHECK(!exists(scratch("rb_relative.bin")));
|
||||
}
|
||||
|
||||
static void testExistingDestinationRefusedUntouched() {
|
||||
LandedFileJournal journal;
|
||||
const std::string path = scratch("rb_existing.bin");
|
||||
const std::vector<std::uint8_t> original = patternBytes(16, 0x60);
|
||||
writeScratchFile(path, original);
|
||||
|
||||
CHECK(!journal.writeLandedFile(path, PayloadBuffer(patternBytes(8, 1))));
|
||||
CHECK(readAll(path) == original); // never overwritten
|
||||
CHECK(journal.empty()); // a refused write is not recorded
|
||||
|
||||
const RollbackResult result = journal.rollback();
|
||||
CHECK(result.deletedCount == 0);
|
||||
CHECK(exists(path)); // rollback cannot touch a file it did not write
|
||||
removeQuietly(path);
|
||||
}
|
||||
|
||||
static void testEmptyPayloadRefused() {
|
||||
LandedFileJournal journal;
|
||||
const std::string path = scratch("rb_empty.bin");
|
||||
CHECK(!journal.writeLandedFile(path, PayloadBuffer{}));
|
||||
CHECK(!exists(path));
|
||||
CHECK(journal.empty());
|
||||
}
|
||||
|
||||
static void testRollbackDeletesExactlyTheRecordedSet() {
|
||||
LandedFileJournal journal;
|
||||
const std::string a = scratch("rb_a.bin");
|
||||
const std::string c = scratch("rb_c.bin");
|
||||
const std::string bystander = scratch("rb_bystander.bin");
|
||||
CHECK(journal.writeLandedFile(a, PayloadBuffer(patternBytes(8, 1))));
|
||||
CHECK(journal.writeLandedFile(c, PayloadBuffer(patternBytes(8, 2))));
|
||||
writeScratchFile(bystander, patternBytes(8, 3)); // not journal-written
|
||||
|
||||
const RollbackResult result = journal.rollback();
|
||||
CHECK(result.deletedCount == 2);
|
||||
CHECK(result.alreadyAbsentCount == 0);
|
||||
CHECK(result.failedCount == 0);
|
||||
CHECK(!result.refused);
|
||||
CHECK(!exists(a));
|
||||
CHECK(!exists(c));
|
||||
CHECK(exists(bystander)); // exactly the given files, nothing else
|
||||
CHECK(journal.empty());
|
||||
|
||||
const RollbackResult second = journal.rollback(); // cleared: a no-op
|
||||
CHECK(second.deletedCount == 0);
|
||||
CHECK(exists(bystander));
|
||||
removeQuietly(bystander);
|
||||
}
|
||||
|
||||
static void testVanishedFileIsToleratedNotFailed() {
|
||||
LandedFileJournal journal;
|
||||
const std::string path = scratch("rb_gone.bin");
|
||||
CHECK(journal.writeLandedFile(path, PayloadBuffer(patternBytes(8, 1))));
|
||||
removeQuietly(path); // vanished between land and rollback
|
||||
CHECK(!exists(path));
|
||||
|
||||
const RollbackResult result = journal.rollback();
|
||||
CHECK(result.deletedCount == 0);
|
||||
CHECK(result.alreadyAbsentCount == 1);
|
||||
CHECK(result.failedCount == 0);
|
||||
}
|
||||
|
||||
static void testIndexCommitDisarmsRollback() {
|
||||
// Once the index references these files the carve-out no longer covers them, so a
|
||||
// late failure in the verb must not be able to delete indexed bytes.
|
||||
LandedFileJournal journal;
|
||||
const std::string path = scratch("rb_committed.bin");
|
||||
const std::vector<std::uint8_t> bytes = patternBytes(8, 1);
|
||||
CHECK(journal.writeLandedFile(path, PayloadBuffer(bytes)));
|
||||
|
||||
journal.markIndexCommitted();
|
||||
CHECK(journal.indexCommitted());
|
||||
|
||||
const RollbackResult result = journal.rollback();
|
||||
CHECK(result.refused);
|
||||
CHECK(result.deletedCount == 0);
|
||||
CHECK(readAll(path) == bytes); // untouched
|
||||
CHECK(!journal.empty()); // the record survives the refusal
|
||||
|
||||
// Landing more files after the commit would produce unrollbackable state.
|
||||
const std::string late = scratch("rb_late.bin");
|
||||
CHECK(!journal.writeLandedFile(late, PayloadBuffer(patternBytes(8, 2))));
|
||||
CHECK(!exists(late));
|
||||
|
||||
removeQuietly(path);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testLandRecordsOnSuccessOnly();
|
||||
testLandNonAsciiPathRoundTripsAsUtf8();
|
||||
testRelativeInputIsRecordedAbsolute();
|
||||
testExistingDestinationRefusedUntouched();
|
||||
testEmptyPayloadRefused();
|
||||
testRollbackDeletesExactlyTheRecordedSet();
|
||||
testVanishedFileIsToleratedNotFailed();
|
||||
testIndexCommitDisarmsRollback();
|
||||
|
||||
if (g_fail == 0) std::printf("package_rollback: all tests passed\n");
|
||||
else std::printf("package_rollback: %d CHECK(s) FAILED\n", g_fail);
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
// The corpus driven through both verbs — no REAPER, no framework. Where
|
||||
// test_package_compat asserts what the frozen bytes DECODE to, this file asserts what
|
||||
// the import and export verbs DO with them: the payload bytes survive a full
|
||||
// export -> import -> export, and every refusal in the corpus refuses before a planner
|
||||
// or a filesystem write is reached.
|
||||
|
||||
#include "../src/shell/package/import_landing.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "../src/core/package/bank_package.h"
|
||||
#include "../src/shell/package/export_bank.h"
|
||||
#include "../src/shell/package/package_path.h"
|
||||
#include "../src/shell/persist/session.h"
|
||||
#include "package_fixtures.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)
|
||||
|
||||
static const char* kTag = "1754000000";
|
||||
static const char* kImportedBankId = "bank-imported";
|
||||
|
||||
// --- scratch project ---------------------------------------------------------
|
||||
|
||||
// One project directory per suite, torn down after, so no suite observes another's
|
||||
// bank folder.
|
||||
class Scratch {
|
||||
public:
|
||||
explicit Scratch(const std::string& name) {
|
||||
std::error_code ec;
|
||||
dir_ = pathToUtf8(fs::temp_directory_path(ec) /
|
||||
utf8Path("reasampler_compat_" + name));
|
||||
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 package::bankFolderDir(dir_); }
|
||||
std::string importPath() const { return dir_ + "/in.rsbank"; }
|
||||
std::string exportPath() const { return dir_ + "/out.rsbank"; }
|
||||
|
||||
private:
|
||||
std::string dir_;
|
||||
};
|
||||
|
||||
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>());
|
||||
}
|
||||
|
||||
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()));
|
||||
}
|
||||
|
||||
// Copies a committed fixture to the path the verb will be pointed at. Fails loudly on a
|
||||
// size mismatch (empty included): an unreadable OR mangled corpus would otherwise let
|
||||
// every "must refuse" suite pass, since a garbled fixture still refuses, just not for
|
||||
// the reason under test.
|
||||
static bool stageFixture(const Scratch& scratch, const char* fixture,
|
||||
std::size_t expectedSize) {
|
||||
const std::vector<std::uint8_t> bytes = packageFixtureBytes(fixture);
|
||||
if (bytes.size() != expectedSize) {
|
||||
std::printf("FAIL: fixture %s is %zu bytes, expected %zu (path: %s)\n", fixture,
|
||||
bytes.size(), expectedSize, packageFixturePath(fixture).c_str());
|
||||
++g_fail;
|
||||
return false;
|
||||
}
|
||||
writeBytes(scratch.importPath(), bytes);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Each entry's payload bytes, sliced out of a whole package file by its own layout.
|
||||
static std::vector<std::vector<std::uint8_t>> payloadsOf(const std::vector<std::uint8_t>& file) {
|
||||
std::vector<std::vector<std::uint8_t>> out;
|
||||
const package::DecodedPackage dec = package::decodePackage(file, file.size());
|
||||
if (dec.status != package::PackageReadability::Readable) return out;
|
||||
for (const package::PackageEntrySpan& span : dec.layout) {
|
||||
const auto begin = file.begin() + static_cast<std::ptrdiff_t>(span.offset);
|
||||
out.emplace_back(begin, begin + static_cast<std::ptrdiff_t>(span.length));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- the sequence the import verb runs, minus REAPER -------------------------
|
||||
|
||||
static ImportLanding runImport(const Scratch& scratch, ReaSamplerSession& session,
|
||||
int* outBirths = nullptr) {
|
||||
LandedFileJournal journal;
|
||||
ImportLanding landing = landPackage(scratch.importPath(), scratch.projectDir(),
|
||||
session.book(), kTag, journal);
|
||||
if (landing.outcome != ImportOutcome::Landed) return landing;
|
||||
int births = 0;
|
||||
const bool applied = applyImportedBank(session.book(), kImportedBankId, landing.plan,
|
||||
[&births](const model::Sample&) { ++births; });
|
||||
CHECK(applied);
|
||||
if (applied) journal.markIndexCommitted();
|
||||
if (outBirths) *outBirths = births;
|
||||
return landing;
|
||||
}
|
||||
|
||||
// --- the round-trip anchor ---------------------------------------------------
|
||||
|
||||
// The fixture IS the first export (written by the shipping build's own export verb), so
|
||||
// importing and re-exporting it closes export -> import -> export over frozen bytes.
|
||||
static void testV1FixtureReExportsByteIdenticalPayloads() {
|
||||
Scratch scratch("roundtrip");
|
||||
if (!stageFixture(scratch, "v1_shipping.rsbank", 1207)) return;
|
||||
|
||||
ReaSamplerSession session;
|
||||
int births = 0;
|
||||
const ImportLanding landing = runImport(scratch, session, &births);
|
||||
CHECK(landing.outcome == ImportOutcome::Landed);
|
||||
CHECK(births == 1);
|
||||
CHECK(landing.plan.landCount == 1);
|
||||
if (landing.plan.entries.size() != 1) { CHECK(false); return; }
|
||||
|
||||
const std::vector<std::vector<std::uint8_t>> sourcePayloads =
|
||||
payloadsOf(packageFixtureBytes("v1_shipping.rsbank"));
|
||||
if (sourcePayloads.size() != 1) { CHECK(false); return; }
|
||||
|
||||
// The landed file is the package's payload verbatim — the first half of the claim.
|
||||
const std::string landed = scratch.bankDir() + "/" + landing.plan.entries[0].destFileName;
|
||||
CHECK(readBytes(landed) == sourcePayloads[0]);
|
||||
|
||||
ExportRequest req;
|
||||
req.projectDir = scratch.projectDir();
|
||||
req.bankId = kImportedBankId;
|
||||
req.destAbsPath = scratch.exportPath();
|
||||
req.exportTimestamp = 1754200000;
|
||||
const ExportOutcome out = exportBank(session, req);
|
||||
CHECK(out.status == ExportStatus::Written);
|
||||
CHECK(out.entriesWritten == 1);
|
||||
|
||||
// The second half: the re-export's payloads, byte for byte. Entry NAMES may legally
|
||||
// differ across the trip — the importer re-spells a bank file and the exporter mints
|
||||
// its own transport name (src/core/package/CLAUDE.md) — the payload bytes may not.
|
||||
CHECK(payloadsOf(readBytes(scratch.exportPath())) == sourcePayloads);
|
||||
}
|
||||
|
||||
// --- the refusals, at the verb rather than the codec -------------------------
|
||||
|
||||
static void testRefuseFixtureRefusesTheWholeImportAndNamesTheWriter() {
|
||||
Scratch scratch("refuse");
|
||||
if (!stageFixture(scratch, "refuse_structural.rsbank", 1207)) return;
|
||||
|
||||
ReaSamplerSession session;
|
||||
const BankBook before = session.book();
|
||||
LandedFileJournal journal;
|
||||
const ImportLanding landing = landPackage(scratch.importPath(), scratch.projectDir(),
|
||||
session.book(), kTag, journal);
|
||||
|
||||
CHECK(landing.outcome == ImportOutcome::TooNew);
|
||||
// Literal, not kPackageFormatVersion-relative: this is the FIXTURE's frozen pair
|
||||
// (2/2), not this build's (see test_package_compat.cpp's file header note).
|
||||
CHECK(landing.header.formatVersion == 2);
|
||||
CHECK(landing.header.minReaderVersion == 2);
|
||||
CHECK(landing.header.writerVersion == "1.9.0");
|
||||
// Nothing planned, nothing on disk, nothing in the index.
|
||||
CHECK(landing.plan.entries.empty());
|
||||
CHECK(!fs::exists(utf8Path(scratch.bankDir())));
|
||||
CHECK(session.book() == before);
|
||||
}
|
||||
|
||||
// The same eight cuts test_package_compat classifies, driven through the verb's
|
||||
// incremental prefix reader — the one caller that can ask requiredPrefixSize for more
|
||||
// bytes than the file holds. Sizes match test_package_compat.cpp's kTruncations.
|
||||
struct TruncationFixture {
|
||||
const char* file;
|
||||
std::size_t size;
|
||||
};
|
||||
|
||||
static const TruncationFixture kTruncationFixtures[] = {
|
||||
{"trunc_magic.rsbank", 2},
|
||||
{"trunc_version_pair.rsbank", 10},
|
||||
{"trunc_writer_semver.rsbank", 18},
|
||||
{"trunc_manifest_length.rsbank", 23},
|
||||
{"trunc_manifest_body.rsbank", 466},
|
||||
{"trunc_payload_start.rsbank", 907},
|
||||
{"trunc_payload_middle.rsbank", 1057},
|
||||
{"trunc_one_short.rsbank", 1206},
|
||||
};
|
||||
|
||||
static void testEveryTruncationRefusesTheImportAsMalformed() {
|
||||
for (const TruncationFixture& fixture : kTruncationFixtures) {
|
||||
Scratch scratch(std::string("trunc_") + fixture.file);
|
||||
if (!stageFixture(scratch, fixture.file, fixture.size)) continue;
|
||||
|
||||
ReaSamplerSession session;
|
||||
const BankBook before = session.book();
|
||||
LandedFileJournal journal;
|
||||
const ImportLanding landing = landPackage(scratch.importPath(), scratch.projectDir(),
|
||||
session.book(), kTag, journal);
|
||||
if (landing.outcome != ImportOutcome::Malformed) {
|
||||
std::printf("FAIL: %s imported as outcome %d, expected Malformed\n", fixture.file,
|
||||
static_cast<int>(landing.outcome));
|
||||
++g_fail;
|
||||
}
|
||||
CHECK(landing.plan.entries.empty());
|
||||
CHECK(!fs::exists(utf8Path(scratch.bankDir())));
|
||||
CHECK(session.book() == before);
|
||||
}
|
||||
}
|
||||
|
||||
// Sizes match test_package_compat.cpp's kHostiles.
|
||||
struct HostileFixtureFile {
|
||||
const char* file;
|
||||
std::size_t size;
|
||||
};
|
||||
|
||||
static const HostileFixtureFile kHostileFixtures[] = {
|
||||
{"hostile_name_dotdot.rsbank", 624},
|
||||
{"hostile_name_parent_slash.rsbank", 633},
|
||||
{"hostile_name_parent_backslash.rsbank", 634},
|
||||
{"hostile_name_subdir_slash.rsbank", 634},
|
||||
{"hostile_name_drive_absolute.rsbank", 643},
|
||||
{"hostile_name_unc_absolute.rsbank", 646},
|
||||
{"hostile_path_dotdot_slash.rsbank", 641},
|
||||
{"hostile_path_dotdot_backslash.rsbank", 640},
|
||||
{"hostile_path_rooted.rsbank", 635},
|
||||
{"hostile_path_drive_absolute.rsbank", 643},
|
||||
{"hostile_path_unc_absolute.rsbank", 646},
|
||||
};
|
||||
|
||||
static void testEveryHostileNameIsRefusedBeforeThePlannerRuns() {
|
||||
for (const HostileFixtureFile& fixture : kHostileFixtures) {
|
||||
Scratch scratch(std::string("hostile_") + fixture.file);
|
||||
if (!stageFixture(scratch, fixture.file, fixture.size)) continue;
|
||||
|
||||
ReaSamplerSession session;
|
||||
const BankBook before = session.book();
|
||||
LandedFileJournal journal;
|
||||
const ImportLanding landing = landPackage(scratch.importPath(), scratch.projectDir(),
|
||||
session.book(), kTag, journal);
|
||||
if (landing.outcome != ImportOutcome::Malformed) {
|
||||
std::printf("FAIL: %s imported as outcome %d, expected Malformed\n", fixture.file,
|
||||
static_cast<int>(landing.outcome));
|
||||
++g_fail;
|
||||
}
|
||||
// planImport is the ONLY producer of a non-empty plan and it runs after the
|
||||
// decode — an empty one is how "refused before any planner" is observed here.
|
||||
CHECK(landing.plan.entries.empty());
|
||||
CHECK(landing.plan.bankDisplayName.empty());
|
||||
CHECK(!fs::exists(utf8Path(scratch.bankDir())));
|
||||
CHECK(session.book() == before);
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
testV1FixtureReExportsByteIdenticalPayloads();
|
||||
testRefuseFixtureRefusesTheWholeImportAndNamesTheWriter();
|
||||
testEveryTruncationRefusesTheImportAsMalformed();
|
||||
testEveryHostileNameIsRefusedBeforeThePlannerRuns();
|
||||
|
||||
if (g_fail == 0) {
|
||||
std::printf("package_round_trip_tests: all passed\n");
|
||||
return 0;
|
||||
}
|
||||
std::printf("package_round_trip_tests: %d failure(s)\n", g_fail);
|
||||
return 1;
|
||||
}
|
||||
@@ -65,9 +65,11 @@ static void testRealtimeIsUnsupportedOffline() {
|
||||
}
|
||||
|
||||
static void testEveryRenderSourceLabelIsPinnedVerbatim() {
|
||||
// docs/VERIFICATION.md asks Daniel to report the refusal's `Render source:` line
|
||||
// back verbatim, so every label is pinned to its literal — a typo in any of them
|
||||
// breaks the report that quotes it, and only a literal catches that.
|
||||
// docs/VERIFICATION.md's short-render bullet asks Daniel to report the refusal
|
||||
// line back verbatim, and that line always carries the render source
|
||||
// (render_bounds_gate.cpp appends "Render source: <label>."), so every label
|
||||
// is pinned to its literal — a typo in any of them breaks the report that
|
||||
// quotes it, and only a literal catches that.
|
||||
CHECK(std::strcmp(renderSourceLabel(SourceMode::MasterMix), "master mix") == 0);
|
||||
CHECK(std::strcmp(renderSourceLabel(SourceMode::TimeSelection), "master mix") == 0);
|
||||
CHECK(std::strcmp(renderSourceLabel(SourceMode::SelectedTracks),
|
||||
@@ -81,9 +83,10 @@ static void testEveryRenderSourceLabelIsPinnedVerbatim() {
|
||||
static void testLabelsSeparateExactlyWhatTheRenderSeparates() {
|
||||
// The labels partition the offline modes the way RENDER_SETTINGS does, and no
|
||||
// finer: same bits => same words (MasterMix/TimeSelection both render the master
|
||||
// mix, custom-time-bounded), different bits => different words. Naming two modes
|
||||
// apart that render identically would put a distinction in a bug report that does
|
||||
// not exist in the render.
|
||||
// mix, unqualified — both hand their window over the same time-selection bounds
|
||||
// mode), different bits => different words. Naming two modes apart that render
|
||||
// identically would put a distinction in a bug report that does not exist in
|
||||
// the render.
|
||||
const SourceMode offline[] = {
|
||||
SourceMode::MasterMix, SourceMode::TimeSelection, SourceMode::SelectedTracks,
|
||||
SourceMode::SelectedItems, SourceMode::RazorArea,
|
||||
@@ -111,35 +114,39 @@ static void testLabelsSeparateExactlyWhatTheRenderSeparates() {
|
||||
|
||||
// --- tail: TailMode -> RENDER_* mapping (docs/product/capture-tail.md) --------
|
||||
|
||||
static TailRenderSettings tailFor(TailMode mode, double manualTailMs) {
|
||||
return tailRenderSettingsFor(mode, manualTailMs);
|
||||
}
|
||||
|
||||
static void testTailNoneIsExactBounds() {
|
||||
// None -> exact bounds, byte-identical to the pre-tail capture: tail flag clear,
|
||||
// 0 ms, disable-all normalize (the current default), no trim. Asserting the exact
|
||||
// bit values (not just "some value") pins the byte-identical contract: if the
|
||||
// mapping regressed to set a tail bit or a non-disable-all normalize, this fails.
|
||||
TailRenderSettings t = tailRenderSettingsFor(TailMode::None, 0.0);
|
||||
TailRenderSettings t = tailFor(TailMode::None, 0.0);
|
||||
CHECK(t.tailFlag == kTailFlagNone); // 0
|
||||
CHECK(t.tailMs == 0.0);
|
||||
CHECK(t.normalize == kNormalizeDisableAll); // 262144
|
||||
CHECK(t.trimEnd == 0.0);
|
||||
// manualTailMs must be ignored for None (a stray tail from a leftover ms is the bug).
|
||||
TailRenderSettings t2 = tailRenderSettingsFor(TailMode::None, 5000.0);
|
||||
TailRenderSettings t2 = tailFor(TailMode::None, 5000.0);
|
||||
CHECK(t2.tailFlag == kTailFlagNone);
|
||||
CHECK(t2.tailMs == 0.0);
|
||||
}
|
||||
|
||||
static void testTailAutoIsSurgicalTrim() {
|
||||
// Auto -> custom-bounds tail bit, 8 s cap, SURGICAL normalize (ONLY &32768), and
|
||||
// the -72 dB TRIMEND ratio. The disable-all bit must NOT be set (it is semantically
|
||||
// opposed to trim — this assertion catches a regression to the None normalize).
|
||||
TailRenderSettings t = tailRenderSettingsFor(TailMode::Auto, 0.0);
|
||||
CHECK(t.tailFlag == kTailFlagCustomBounds); // &1
|
||||
// Auto -> the time-selection tail bit, 8 s cap, SURGICAL normalize (ONLY &32768),
|
||||
// and the -72 dB TRIMEND ratio. The disable-all bit must NOT be set (it is
|
||||
// semantically opposed to trim — this catches a regression to the None normalize).
|
||||
TailRenderSettings t = tailFor(TailMode::Auto, 0.0);
|
||||
CHECK(t.tailFlag == kTailFlagTimeSelection); // &4
|
||||
CHECK(t.tailMs == kMaxTailMs); // 8000
|
||||
CHECK(t.normalize == kNormalizeTrimEnd); // exactly 32768, nothing else
|
||||
CHECK((t.normalize & kNormalizeDisableAll) == 0); // disable-all is NOT set
|
||||
// TRIMEND is the derived -72 dB ratio ~= 0.00025119 (the DAW-confirm value).
|
||||
CHECK(std::fabs(t.trimEnd - 0.00025119) < 1e-8);
|
||||
// manualTailMs is ignored for Auto (Auto always uses the 8 s cap).
|
||||
CHECK(tailRenderSettingsFor(TailMode::Auto, 3000.0).tailMs == kMaxTailMs);
|
||||
CHECK(tailFor(TailMode::Auto, 3000.0).tailMs == kMaxTailMs);
|
||||
}
|
||||
|
||||
static void testAutoTrimRatioDerivesFromDb() {
|
||||
@@ -147,17 +154,17 @@ static void testAutoTrimRatioDerivesFromDb() {
|
||||
// float — recompute it independently and require an exact match with the mapping.
|
||||
double expected = std::pow(10.0, kAutoTrimThresholdDb / 20.0);
|
||||
CHECK(autoTrimEndRatio() == expected);
|
||||
CHECK(tailRenderSettingsFor(TailMode::Auto, 0.0).trimEnd == expected);
|
||||
CHECK(tailFor(TailMode::Auto, 0.0).trimEnd == expected);
|
||||
// Sanity: -72 dB is well below unity but above zero.
|
||||
CHECK(expected > 0.0 && expected < 0.001);
|
||||
}
|
||||
|
||||
static void testTailManualFixedNoTrim() {
|
||||
// Manual -> custom-bounds tail, the requested ms (within cap), disable-all
|
||||
// Manual -> the time-selection tail bit, the requested ms (within cap), disable-all
|
||||
// normalize (no trim). A Manual capture is a fixed tail, so it keeps today's
|
||||
// disable-all exactly like the no-tail path.
|
||||
TailRenderSettings t = tailRenderSettingsFor(TailMode::Manual, 2500.0);
|
||||
CHECK(t.tailFlag == kTailFlagCustomBounds);
|
||||
TailRenderSettings t = tailFor(TailMode::Manual, 2500.0);
|
||||
CHECK(t.tailFlag == kTailFlagTimeSelection);
|
||||
CHECK(t.tailMs == 2500.0);
|
||||
CHECK(t.normalize == kNormalizeDisableAll);
|
||||
CHECK(t.trimEnd == 0.0);
|
||||
@@ -165,12 +172,37 @@ static void testTailManualFixedNoTrim() {
|
||||
|
||||
static void testTailManualClampsToCap() {
|
||||
// The 8 s cap is a runaway guard that applies to Manual too: ms > 8000 -> 8000.
|
||||
CHECK(tailRenderSettingsFor(TailMode::Manual, 9000.0).tailMs == kMaxTailMs);
|
||||
CHECK(tailRenderSettingsFor(TailMode::Manual, 8000.0).tailMs == kMaxTailMs);
|
||||
CHECK(tailFor(TailMode::Manual, 9000.0).tailMs == kMaxTailMs);
|
||||
CHECK(tailFor(TailMode::Manual, 8000.0).tailMs == kMaxTailMs);
|
||||
// Below the cap is passed through unchanged.
|
||||
CHECK(tailRenderSettingsFor(TailMode::Manual, 100.0).tailMs == 100.0);
|
||||
CHECK(tailFor(TailMode::Manual, 100.0).tailMs == 100.0);
|
||||
// A negative request floors to 0 (no negative tail leaks into RENDER_TAILMS).
|
||||
CHECK(tailRenderSettingsFor(TailMode::Manual, -50.0).tailMs == 0.0);
|
||||
CHECK(tailFor(TailMode::Manual, -50.0).tailMs == 0.0);
|
||||
}
|
||||
|
||||
// --- bounds mode: RENDER_BOUNDSFLAG + the tail bit paired with it ---------------
|
||||
|
||||
static void testTheBoundsModeIsTheTimeSelectionAndItsTailBitIsPairedWithIt() {
|
||||
// Literals from the SDK header, pinned as numbers so neither can drift onto
|
||||
// another bounds mode's value: RENDER_BOUNDSFLAG 2 = time selection (~3042), and
|
||||
// RENDER_TAILFLAG's bits are keyed per bounds mode, &4 = time selection (~3047).
|
||||
// The custom-bounds pair (0 / &1) is DELIBERATELY absent — that mode floors the
|
||||
// window to the millisecond (render_settings.h) and must not come back.
|
||||
CHECK(kRenderBoundsTimeSelection == 2);
|
||||
CHECK(kTailFlagTimeSelection == 4);
|
||||
CHECK(kTailFlagNone == 0);
|
||||
}
|
||||
|
||||
static void testEveryTailModeSetsTheBitTheBoundsModeReads() {
|
||||
// A tail set under a different bounds mode's bit renders no tail at all, so both
|
||||
// tail-bearing modes must carry &4 — a fix applied to Auto alone would leave
|
||||
// Manual silently tailless.
|
||||
for (TailMode mode : {TailMode::Auto, TailMode::Manual})
|
||||
CHECK(tailRenderSettingsFor(mode, 2500.0).tailFlag == kTailFlagTimeSelection);
|
||||
|
||||
// None is exact bounds: no tail bit at all, whatever ms it is handed.
|
||||
CHECK(tailRenderSettingsFor(TailMode::None, 5000.0).tailFlag == kTailFlagNone);
|
||||
CHECK(tailRenderSettingsFor(TailMode::None, 0.0).tailFlag == kTailFlagNone);
|
||||
}
|
||||
|
||||
// --- realtimeRecordWindowEnd: the T2 record-window extension -----------------
|
||||
@@ -448,6 +480,8 @@ int main() {
|
||||
testAutoTrimRatioDerivesFromDb();
|
||||
testTailManualFixedNoTrim();
|
||||
testTailManualClampsToCap();
|
||||
testTheBoundsModeIsTheTimeSelectionAndItsTailBitIsPairedWithIt();
|
||||
testEveryTailModeSetsTheBitTheBoundsModeReads();
|
||||
testRealtimeWindowNoneIsExact();
|
||||
testRealtimeWindowAutoAddsCap();
|
||||
testRealtimeWindowManualAddsClampedLength();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Standalone tests for reasampler::render_window — no REAPER, no framework.
|
||||
// Covers the bounds-equality number (a window's exact frame count at the project
|
||||
// rate), the verdict the offline backend refuses a capture on, and the predicate
|
||||
// rate), the verdict the offline backend refuses a capture on, the predicate
|
||||
// that decides whether REAPER's selected-items render source can express a
|
||||
// requested window at all.
|
||||
// requested window at all, and the millisecond-floor shape a refusal quotes.
|
||||
|
||||
#include "../src/core/capture/render_window.h"
|
||||
|
||||
@@ -226,6 +226,221 @@ static void testMultiItemUnionExtent() {
|
||||
CHECK(!itemExtentPrintsWindow(1.0, 4.0, 1.0, 9.0, 48000));
|
||||
}
|
||||
|
||||
// --- msFlooredEndFrameCount: the shape both live short renders had ------------
|
||||
|
||||
static void testMillisecondFlooredEndReproducesBothShortRenders() {
|
||||
// Both DAW observations, as arithmetic. 48 kHz, TailMode::None, start at 0: the
|
||||
// requested window's count, and the count its end floored to the millisecond
|
||||
// holds — which is what each render actually printed.
|
||||
CHECK(frameCountFor(0.0, 4.067797, 48000) == 195254);
|
||||
CHECK(msFlooredEndFrameCount(0.0, 4.067797, 48000) == 195216);
|
||||
CHECK(frameCountFor(0.0, 4.067797, 48000) -
|
||||
msFlooredEndFrameCount(0.0, 4.067797, 48000) == 38);
|
||||
|
||||
CHECK(frameCountFor(0.0, 1.655172, 48000) == 79448);
|
||||
CHECK(msFlooredEndFrameCount(0.0, 1.655172, 48000) == 79440);
|
||||
CHECK(frameCountFor(0.0, 1.655172, 48000) -
|
||||
msFlooredEndFrameCount(0.0, 1.655172, 48000) == 8);
|
||||
}
|
||||
|
||||
static void testTheSixDecimalDisplayDidNotCreateTheEffect() {
|
||||
// Both reported ends were printed to six decimals by the refusal. Each is one 4/4
|
||||
// bar — at 59 BPM and at 145 BPM — so the full-precision doubles behind them are
|
||||
// 240/59 and 240/145. Same counts either way: the display rounding is not what
|
||||
// produces the shortfall.
|
||||
CHECK(frameCountFor(0.0, 240.0 / 59.0, 48000) == 195254);
|
||||
CHECK(msFlooredEndFrameCount(0.0, 240.0 / 59.0, 48000) == 195216);
|
||||
CHECK(frameCountFor(0.0, 240.0 / 145.0, 48000) == 79448);
|
||||
CHECK(msFlooredEndFrameCount(0.0, 240.0 / 145.0, 48000) == 79440);
|
||||
}
|
||||
|
||||
static void testWindowAlreadyOnTheMillisecondGridLosesNothing() {
|
||||
// The "sometimes it works" case: a bar at 120 BPM is exactly 2 s.
|
||||
CHECK(msFlooredEndFrameCount(0.0, 2.0, 48000) == frameCountFor(0.0, 2.0, 48000));
|
||||
|
||||
// The binary-representation trap a bare floor would fall into. The premise, not an
|
||||
// outcome: 1.007 s is a whole millisecond that really does land BELOW 1007 ms in
|
||||
// double, so flooring it without a tolerance drops a millisecond from a window
|
||||
// already on the grid.
|
||||
CHECK(1.007 * 1000.0 < 1007.0);
|
||||
CHECK(frameCountFor(0.0, 1.007, 48000) == 48336);
|
||||
CHECK(msFlooredEndFrameCount(0.0, 1.007, 48000) == 48336);
|
||||
// Same end reached from a non-zero start, so nothing here rests on the window
|
||||
// beginning at 0.
|
||||
CHECK(msFlooredEndFrameCount(0.5, 1.007, 48000) ==
|
||||
frameCountFor(0.5, 1.007, 48000));
|
||||
}
|
||||
|
||||
static void testOneFrameOfRemainderStillFloors() {
|
||||
// The whole-millisecond tolerance must sit far below a frame, or it would swallow
|
||||
// the very remainder this diagnostic exists to find. A remainder JUST BELOW a
|
||||
// millisecond boundary is the discriminating case: one frame short of 1.0 s is
|
||||
// 999.979166 ms, only ~0.0208 ms off the next whole millisecond. The shipped
|
||||
// nanosecond tolerance still floors it down; a tolerance any wider than ~0.021 ms
|
||||
// would snap it up to the millisecond instead and this test would then see 48000,
|
||||
// not 47952 — which is what would fail if the tolerance regressed to something
|
||||
// that wide.
|
||||
const double oneFrame = 1.0 / 48000.0;
|
||||
CHECK(frameCountFor(0.0, 1.0 - oneFrame, 48000) == 47999);
|
||||
CHECK(msFlooredEndFrameCount(0.0, 1.0 - oneFrame, 48000) == 47952);
|
||||
}
|
||||
|
||||
static void testMillisecondFloorAt44100WhereAMillisecondIsNotWholeFrames() {
|
||||
// 44.1 kHz: a millisecond is 44.1 frames, so a floored end cannot be described as
|
||||
// dropping a whole number of frames — the count still resolves exactly.
|
||||
CHECK(frameCountFor(0.0, 0.0105, 44100) == 463);
|
||||
CHECK(msFlooredEndFrameCount(0.0, 0.0105, 44100) == 441);
|
||||
// And a window that IS on the millisecond grid there is untouched, even though its
|
||||
// edge is not on a frame boundary.
|
||||
CHECK(frameCountFor(0.0, 0.010, 44100) == 441);
|
||||
CHECK(msFlooredEndFrameCount(0.0, 0.010, 44100) == 441);
|
||||
}
|
||||
|
||||
static void testASubMillisecondStartWouldNotHideItself() {
|
||||
// Both observations started at 0.000000s, the one value that hides a start-side
|
||||
// truncation. A window whose START carries a sub-millisecond remainder counts from
|
||||
// that exact start...
|
||||
const double start = 1.0001724, end = 2.0001724;
|
||||
CHECK(frameCountFor(start, end, 48000) == 48000);
|
||||
// ...so a start floored to the millisecond would print a DIFFERENT count — 8 frames
|
||||
// more, the same remainder the second observation lost off its end. A start-side
|
||||
// truncation is therefore visible to the same frame-count gate, not silent.
|
||||
CHECK(frameCountFor(1.000, end, 48000) == 48008);
|
||||
CHECK(!renderHonoredBounds(frameCountFor(start, end, 48000),
|
||||
frameCountFor(1.000, end, 48000)));
|
||||
}
|
||||
|
||||
static void testTheTwoLiveShortRendersPinnedAtFullPrecision() {
|
||||
// 1.6551724137931001 is the console's own %.17g read-back. 4.0677966101694913 is
|
||||
// the double nearest the six-decimal value (4.067797) the earlier refusal actually
|
||||
// printed -- that refusal predates the %.17g printer (git history has no commit
|
||||
// introducing this literal as a console value), so it is a reconstruction, not a
|
||||
// captured one. 240/145 and 240/59 (testTheSixDecimalDisplayDidNotCreateTheEffect)
|
||||
// produce the SAME counts as the literals here, so this test cannot distinguish the
|
||||
// real value from the reconstruction either -- it pins the count regression (full
|
||||
// precision or six-decimal input, the frame counts agree), not which double REAPER
|
||||
// was really handed.
|
||||
CHECK(frameCountFor(0.0, 1.6551724137931001, 48000) == 79448);
|
||||
CHECK(msFlooredEndFrameCount(0.0, 1.6551724137931001, 48000) == 79440);
|
||||
|
||||
CHECK(frameCountFor(0.0, 4.0677966101694913, 48000) == 195254);
|
||||
CHECK(msFlooredEndFrameCount(0.0, 4.0677966101694913, 48000) == 195216);
|
||||
|
||||
// And the counts REAPER produced are outside the gate's tolerance in both cases —
|
||||
// the refusals were correct, not an artifact of the one-frame slack.
|
||||
CHECK(!renderHonoredBounds(79448, 79440));
|
||||
CHECK(!renderHonoredBounds(195254, 195216));
|
||||
}
|
||||
|
||||
// --- isOnMillisecondGrid: whether an observation can speak to an edge ----------
|
||||
|
||||
static void testOnGridRecognizesWholeMillisecondsIncludingTheBinaryTrap() {
|
||||
CHECK(isOnMillisecondGrid(0.0));
|
||||
CHECK(isOnMillisecondGrid(2.0));
|
||||
CHECK(isOnMillisecondGrid(0.001));
|
||||
// 1.007 s does not multiply to exactly 1007.0 in double (pinned as the premise in
|
||||
// testWindowAlreadyOnTheMillisecondGridLosesNothing) and must still read as on-grid.
|
||||
CHECK(isOnMillisecondGrid(1.007));
|
||||
// A whole millisecond at 44.1 kHz is 44.1 frames — off the frame grid, on this one.
|
||||
CHECK(isOnMillisecondGrid(0.010));
|
||||
}
|
||||
|
||||
static void testOffGridRecognizesASubMillisecondRemainder() {
|
||||
CHECK(!isOnMillisecondGrid(1.6551724137931001));
|
||||
CHECK(!isOnMillisecondGrid(1.0001724));
|
||||
// One frame short of a whole second at 48 kHz is ~0.0208 ms off the grid — the
|
||||
// tightest remainder this predicate has to keep seeing.
|
||||
CHECK(!isOnMillisecondGrid(1.0 - 1.0 / 48000.0));
|
||||
}
|
||||
|
||||
// --- the settled time-selection observations, as pure arithmetic ---------------
|
||||
//
|
||||
// Two live 48 kHz TailMode::None renders on RENDER_BOUNDSFLAG=2 came back EXACT at
|
||||
// 97627 frames. The console printed run TWO's start verbatim (2.0338983050847457s);
|
||||
// run ONE started at 0s and its end was never printed, so the value below is a
|
||||
// reconstruction from run two's own printed start — it pins the count, not which
|
||||
// double REAPER was handed.
|
||||
|
||||
static void testTheSettledExactRenderOnTheOnGridStart() {
|
||||
CHECK(frameCountFor(0.0, 2.0338983050847457, 48000) == 97627);
|
||||
// Run one could not test the START: 0s is on the grid, which floor, ceil and round
|
||||
// all leave alone, so a start-flooring render prints the identical count.
|
||||
CHECK(isOnMillisecondGrid(0.0));
|
||||
// Its END, though, WAS under test — a floored end would have printed 43 frames fewer.
|
||||
CHECK(msFlooredEndFrameCount(0.0, 2.0338983050847457, 48000) == 97584);
|
||||
CHECK(!renderHonoredBounds(97627, 97584));
|
||||
}
|
||||
|
||||
static void testTheSettledExactRenderTestedBothEdges() {
|
||||
// Run two: both edges carry a sub-millisecond remainder, and the render still
|
||||
// printed the window's exact count.
|
||||
const double start = 2.0338983050847457, end = 4.0677966101694913;
|
||||
CHECK(!isOnMillisecondGrid(start));
|
||||
CHECK(!isOnMillisecondGrid(end));
|
||||
CHECK(frameCountFor(start, end, 48000) == 97627);
|
||||
|
||||
// What makes that EXACT proof rather than a coincidence: NO millisecond-floored
|
||||
// model of this window reproduces 97627, and every one of them sits outside the
|
||||
// gate's one-frame tolerance. This is the assertion the whole experiment rests on.
|
||||
const long long startAlone = frameCountFor(2.033, end, 48000);
|
||||
const long long endAlone = frameCountFor(start, 4.067, 48000);
|
||||
const long long bothTogether = frameCountFor(2.033, 4.067, 48000);
|
||||
CHECK(startAlone == 97670);
|
||||
CHECK(endAlone == 97589);
|
||||
CHECK(bothTogether == 97632);
|
||||
CHECK(!renderHonoredBounds(97627, startAlone));
|
||||
CHECK(!renderHonoredBounds(97627, endAlone));
|
||||
CHECK(!renderHonoredBounds(97627, bothTogether));
|
||||
}
|
||||
|
||||
static void testOnAndOffGridWindowsAreHonoredIdentically() {
|
||||
// Nothing on the settled path may treat a grid-aligned window differently from one
|
||||
// carrying a remainder — the whole point of leaving the flooring channel behind.
|
||||
const double onStart = 1.000, onEnd = 2.000;
|
||||
const double offStart = 1.0001724, offEnd = 2.0001724;
|
||||
CHECK(isOnMillisecondGrid(onStart));
|
||||
CHECK(isOnMillisecondGrid(onEnd));
|
||||
CHECK(!isOnMillisecondGrid(offStart));
|
||||
CHECK(!isOnMillisecondGrid(offEnd));
|
||||
|
||||
const long long on = frameCountFor(onStart, onEnd, 48000);
|
||||
const long long off = frameCountFor(offStart, offEnd, 48000);
|
||||
CHECK(on == 48000);
|
||||
CHECK(off == 48000);
|
||||
|
||||
// The discriminating half: a render that landed the FLOORED count would be
|
||||
// refused on the off-grid window (47992 against the required 48000, an
|
||||
// 8-frame gap) but honored on the on-grid one, where flooring changes
|
||||
// nothing. If the floor ever came back on the settled path, this is what
|
||||
// would start failing.
|
||||
const long long offFloored = msFlooredEndFrameCount(offStart, offEnd, 48000);
|
||||
const long long onFloored = msFlooredEndFrameCount(onStart, onEnd, 48000);
|
||||
CHECK(offFloored == 47992);
|
||||
CHECK(onFloored == on);
|
||||
CHECK(!renderHonoredBounds(off, offFloored));
|
||||
CHECK(renderHonoredBounds(on, onFloored));
|
||||
}
|
||||
|
||||
static void testOnAndOffGridAt44100WhereAMillisecondIsNotWholeFrames() {
|
||||
// 44.1 kHz: a millisecond is 44.1 frames, so a grid-aligned window's edges are NOT
|
||||
// frame-aligned. The exact counts must still be exact and the two must still be
|
||||
// judged identically.
|
||||
const double onStart = 1.000, onEnd = 2.000;
|
||||
const double offStart = 1.0001724, offEnd = 2.0001724;
|
||||
const long long on = frameCountFor(onStart, onEnd, 44100);
|
||||
const long long off = frameCountFor(offStart, offEnd, 44100);
|
||||
CHECK(on == 44100);
|
||||
CHECK(off == 44100);
|
||||
const long long offFloored = msFlooredEndFrameCount(offStart, offEnd, 44100);
|
||||
const long long onFloored = msFlooredEndFrameCount(onStart, onEnd, 44100);
|
||||
CHECK(offFloored == 44092);
|
||||
CHECK(onFloored == on);
|
||||
// Same discriminating pair as the 48 kHz case: the floor would be caught
|
||||
// off-grid and invisible on-grid, even where the grid itself isn't frame-aligned.
|
||||
CHECK(!renderHonoredBounds(off, offFloored));
|
||||
CHECK(renderHonoredBounds(on, onFloored));
|
||||
}
|
||||
|
||||
int main() {
|
||||
testFrameCountIsExactNotRounded();
|
||||
testFrameCountIsADifferenceOfIndicesNotADuration();
|
||||
@@ -244,6 +459,19 @@ int main() {
|
||||
testSubFrameDriftStillPrintsTheSameFrames();
|
||||
testUnknownRateFallsBackToExactEquality();
|
||||
testMultiItemUnionExtent();
|
||||
testMillisecondFlooredEndReproducesBothShortRenders();
|
||||
testTheSixDecimalDisplayDidNotCreateTheEffect();
|
||||
testWindowAlreadyOnTheMillisecondGridLosesNothing();
|
||||
testOneFrameOfRemainderStillFloors();
|
||||
testMillisecondFloorAt44100WhereAMillisecondIsNotWholeFrames();
|
||||
testASubMillisecondStartWouldNotHideItself();
|
||||
testTheTwoLiveShortRendersPinnedAtFullPrecision();
|
||||
testOnGridRecognizesWholeMillisecondsIncludingTheBinaryTrap();
|
||||
testOffGridRecognizesASubMillisecondRemainder();
|
||||
testTheSettledExactRenderOnTheOnGridStart();
|
||||
testTheSettledExactRenderTestedBothEdges();
|
||||
testOnAndOffGridWindowsAreHonoredIdentically();
|
||||
testOnAndOffGridAt44100WhereAMillisecondIsNotWholeFrames();
|
||||
|
||||
if (g_fail) { std::printf("%d check(s) FAILED\n", g_fail); return 1; }
|
||||
std::printf("render_window: all checks passed\n");
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
#include "../src/core/capture/track_topology.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdio>
|
||||
#include <vector>
|
||||
|
||||
@@ -79,6 +80,143 @@ static void testSiblingFolderAfterParentClosesIsNotIncluded() {
|
||||
CHECK(sameIndices(directChildIndices(depths, 0), {1, 2}));
|
||||
}
|
||||
|
||||
// --- siblingPlacement -------------------------------------------------------
|
||||
//
|
||||
// Every case asserts the property that actually matters, not just the numbers: the
|
||||
// new track sits at the SOURCE's own nesting level, and the delta total is
|
||||
// unchanged so no track after the insertion moves. `levelsAfter` rebuilds the
|
||||
// post-insertion list and reads the levels straight off it.
|
||||
|
||||
static std::vector<int> depthsAfter(const std::vector<int>& depths,
|
||||
const SiblingPlacement& p) {
|
||||
std::vector<int> out = depths;
|
||||
if (p.precedingIndex >= 0) out[static_cast<std::size_t>(p.precedingIndex)] = p.precedingDepth;
|
||||
out.insert(out.begin() + p.insertIndex, p.newDepth);
|
||||
return out;
|
||||
}
|
||||
|
||||
static int sumOf(const std::vector<int>& v) {
|
||||
int s = 0;
|
||||
for (int d : v) s += d;
|
||||
return s;
|
||||
}
|
||||
|
||||
// Absolute nesting level of track `idx` in a delta list.
|
||||
static int levelAt(const std::vector<int>& depths, int idx) {
|
||||
int level = 0;
|
||||
for (int i = 0; i < idx; ++i) level += depths[static_cast<std::size_t>(i)];
|
||||
return level;
|
||||
}
|
||||
|
||||
// The whole contract in one call: the new track is a sibling (same level as the
|
||||
// source) and nothing downstream shifted (delta total preserved).
|
||||
static void checkIsSibling(const std::vector<int>& before, int srcIdx) {
|
||||
const SiblingPlacement p = siblingPlacement(before, srcIdx);
|
||||
const std::vector<int> after = depthsAfter(before, p);
|
||||
CHECK(sumOf(after) == sumOf(before));
|
||||
CHECK(levelAt(after, p.insertIndex) == levelAt(before, srcIdx));
|
||||
}
|
||||
|
||||
static void testSiblingOfANormalTrackGoesDirectlyBelowIt() {
|
||||
// Three normal tracks at top level; the source is the middle one.
|
||||
const std::vector<int> depths{0, 0, 0};
|
||||
const SiblingPlacement p = siblingPlacement(depths, 1);
|
||||
CHECK(p.insertIndex == 2);
|
||||
CHECK(p.precedingIndex == 1);
|
||||
CHECK(p.precedingDepth == 0); // unchanged
|
||||
CHECK(p.newDepth == 0);
|
||||
checkIsSibling(depths, 1);
|
||||
}
|
||||
|
||||
static void testSiblingOfAMidFolderTrackStaysInsideTheFolder() {
|
||||
// 0: parent, 1: child (the source), 2: last child closing the folder.
|
||||
const std::vector<int> depths{1, 0, -1};
|
||||
const SiblingPlacement p = siblingPlacement(depths, 1);
|
||||
CHECK(p.insertIndex == 2);
|
||||
CHECK(p.precedingDepth == 0);
|
||||
CHECK(p.newDepth == 0); // still inside; track 2 still closes the folder
|
||||
checkIsSibling(depths, 1);
|
||||
}
|
||||
|
||||
static void testSiblingOfTheLastTrackInAFolderInheritsTheClosingDelta() {
|
||||
// The source carries the folder's close, so a naive insert-after would drop the
|
||||
// new track OUTSIDE the folder and bypass the folder bus entirely.
|
||||
const std::vector<int> depths{1, -1, 0};
|
||||
const SiblingPlacement p = siblingPlacement(depths, 1);
|
||||
CHECK(p.insertIndex == 2);
|
||||
CHECK(p.precedingDepth == 0); // the source no longer closes the folder
|
||||
CHECK(p.newDepth == -1); // the new track does
|
||||
checkIsSibling(depths, 1);
|
||||
}
|
||||
|
||||
static void testSiblingOfTheLastTrackInTwoFoldersMovesTheWholeClose() {
|
||||
// 0: outer parent, 1: inner parent, 2: last in BOTH folders (the source).
|
||||
const std::vector<int> depths{1, 1, -2};
|
||||
const SiblingPlacement p = siblingPlacement(depths, 2);
|
||||
CHECK(p.insertIndex == 3);
|
||||
CHECK(p.precedingDepth == 0);
|
||||
CHECK(p.newDepth == -2); // the -2 travels intact
|
||||
checkIsSibling(depths, 2);
|
||||
}
|
||||
|
||||
static void testSiblingOfAFolderParentLandsAfterTheWholeFolder() {
|
||||
// Inserting straight after a folder parent would make the new track its FIRST
|
||||
// CHILD, re-summing the render through the parent's FX and fader.
|
||||
const std::vector<int> depths{1, 0, -1, 0};
|
||||
const SiblingPlacement p = siblingPlacement(depths, 0);
|
||||
CHECK(p.insertIndex == 3); // past the whole folder, not at index 1
|
||||
CHECK(p.precedingIndex == 2);
|
||||
CHECK(p.precedingDepth == -1); // unchanged — track 2 still closes the folder
|
||||
CHECK(p.newDepth == 0);
|
||||
checkIsSibling(depths, 0);
|
||||
}
|
||||
|
||||
static void testSiblingOfTheLastTrackInTheProjectAppends() {
|
||||
const std::vector<int> depths{0, 0};
|
||||
const SiblingPlacement p = siblingPlacement(depths, 1);
|
||||
CHECK(p.insertIndex == 2); // == count: appended
|
||||
CHECK(p.precedingDepth == 0);
|
||||
CHECK(p.newDepth == 0);
|
||||
checkIsSibling(depths, 1);
|
||||
}
|
||||
|
||||
static void testSiblingOfTheLastTrackInTheProjectInsideAFolder() {
|
||||
// The project's last track also closes a folder — the close must still travel.
|
||||
const std::vector<int> depths{1, -1};
|
||||
const SiblingPlacement p = siblingPlacement(depths, 1);
|
||||
CHECK(p.insertIndex == 2);
|
||||
CHECK(p.precedingDepth == 0);
|
||||
CHECK(p.newDepth == -1);
|
||||
checkIsSibling(depths, 1);
|
||||
}
|
||||
|
||||
static void testMalformedDeltaListClampsRatherThanAsserting() {
|
||||
// Deltas summing to -3: more closes than opens, which no well-formed project
|
||||
// produces. The result must still be a legal in-range placement.
|
||||
const std::vector<int> depths{0, -2, -1};
|
||||
const SiblingPlacement p = siblingPlacement(depths, 1);
|
||||
CHECK(p.insertIndex >= 0 && p.insertIndex <= static_cast<int>(depths.size()));
|
||||
CHECK(p.precedingIndex == p.insertIndex - 1);
|
||||
// Clamped at zero rather than tracking a negative nesting level.
|
||||
CHECK(levelAt(depthsAfter(depths, p), p.insertIndex) >= 0);
|
||||
|
||||
// An unterminated folder (deltas summing to +1) is the other direction.
|
||||
const std::vector<int> open{1, 0};
|
||||
const SiblingPlacement q = siblingPlacement(open, 1);
|
||||
CHECK(q.insertIndex == 2);
|
||||
CHECK(q.newDepth <= 0); // never invents a second folder open
|
||||
}
|
||||
|
||||
static void testOutOfRangeSourceIndexClamps() {
|
||||
const std::vector<int> depths{0, 0};
|
||||
// Past the end clamps to the last track; negative clamps to the first.
|
||||
CHECK(siblingPlacement(depths, 99).insertIndex == 2);
|
||||
CHECK(siblingPlacement(depths, -5).insertIndex == 1);
|
||||
// An empty project has nothing to precede the new track.
|
||||
CHECK(siblingPlacement({}, 0).insertIndex == 0);
|
||||
CHECK(siblingPlacement({}, 0).precedingIndex == -1);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testFlatProjectHasNoChildren();
|
||||
testFolderParentReturnsItsDirectChildren();
|
||||
@@ -88,6 +226,16 @@ int main() {
|
||||
testUnterminatedFolderSwallowsTheRest();
|
||||
testSiblingFolderAfterParentClosesIsNotIncluded();
|
||||
|
||||
testSiblingOfANormalTrackGoesDirectlyBelowIt();
|
||||
testSiblingOfAMidFolderTrackStaysInsideTheFolder();
|
||||
testSiblingOfTheLastTrackInAFolderInheritsTheClosingDelta();
|
||||
testSiblingOfTheLastTrackInTwoFoldersMovesTheWholeClose();
|
||||
testSiblingOfAFolderParentLandsAfterTheWholeFolder();
|
||||
testSiblingOfTheLastTrackInTheProjectAppends();
|
||||
testSiblingOfTheLastTrackInTheProjectInsideAFolder();
|
||||
testMalformedDeltaListClampsRatherThanAsserting();
|
||||
testOutOfRangeSourceIndexClamps();
|
||||
|
||||
if (g_fail == 0) std::printf("track_topology: all tests passed\n");
|
||||
return g_fail == 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
// 5. Unknown/stale GUID tolerated (ignore-and-prune, no crash).
|
||||
// 6. JSON round-trip lossless: modes + membership + show-both + snapshots + active.
|
||||
// 7. planToggle park path: fxOffline is empty (shell-expands-FX contract).
|
||||
// 8. Per-FX offline is keyed by FX identity, and the v1 (slot-keyed) blob lifts
|
||||
// into that keying without losing its restore.
|
||||
// 9. Nested-folder toggle: the snapshot store/clear lifecycle survives a re-park
|
||||
// (park-while-parked) so untagged leaves return to visible after toggling back;
|
||||
// guards the in-DAW "all leaves hidden after toggling twice" regression.
|
||||
@@ -64,7 +66,7 @@ static int flagValue(const TrackPlan& p, Flag f) {
|
||||
static void testSerializeGoldenLiteral() {
|
||||
ViewModeModel vm;
|
||||
CHECK(vm.serialize() ==
|
||||
"{\"version\":1,\"activeMode\":\"arrange\",\"modes\":[{\"id\":\"arrange\","
|
||||
"{\"version\":2,\"activeMode\":\"arrange\",\"modes\":[{\"id\":\"arrange\","
|
||||
"\"displayName\":\"Arrange\",\"ordinal\":0},{\"id\":\"design\","
|
||||
"\"displayName\":\"Design\",\"ordinal\":1}],\"membership\":[],"
|
||||
"\"snapshots\":[],\"lanes\":[],\"soloCache\":[]}");
|
||||
@@ -284,7 +286,9 @@ static void testRestoreRoundTripSnapshotValues() {
|
||||
snap.showInMixer = 1;
|
||||
snap.mainSend = 0; // user had it OUT of the mix for their own reason
|
||||
snap.fxEnable = 1;
|
||||
snap.fxOffline = {0, 1, 0}; // slot 1 was already offline before parking
|
||||
// Slot 1's plugin was already offline before parking; each entry carries the
|
||||
// identity of the FX it came from.
|
||||
snap.fxOffline = {{"{FX-A}", 0}, {"{FX-B}", 1}, {"{FX-C}", 0}};
|
||||
|
||||
TrackPlan park = makeParkPlan("{T}", /*fxCount=*/3);
|
||||
CHECK(flagValue(park, Flag::ShowInTcp) == 0);
|
||||
@@ -304,6 +308,10 @@ static void testRestoreRoundTripSnapshotValues() {
|
||||
CHECK(restore.fxOffline[0].offline == false);
|
||||
CHECK(restore.fxOffline[1].offline == true); // was offline pre-park ⇒ stays offline
|
||||
CHECK(restore.fxOffline[2].offline == false);
|
||||
// Each restore op names the FX it was captured from, not just a position —
|
||||
// resolveFxRestore has something to key on even if the chain moved.
|
||||
CHECK(restore.fxOffline[1].keying == FxKeying::Identity);
|
||||
CHECK(restore.fxOffline[1].fxGuid == "{FX-B}");
|
||||
|
||||
// A snapshot entirely at 0 must restore entirely to 0 (no default leaks in).
|
||||
TrackSnapshot zero; // all zeros, empty fxOffline
|
||||
@@ -419,7 +427,8 @@ static void testJsonRoundTrip() {
|
||||
|
||||
// Snapshots: one full, one with a per-FX vector, including the tricky 0-values.
|
||||
TrackSnapshot s1; s1.showInTcp = 1; s1.showInMixer = 0; s1.mainSend = 1;
|
||||
s1.fxEnable = 0; s1.fxOffline = {1, 0, 1, 1};
|
||||
s1.fxEnable = 0;
|
||||
s1.fxOffline = {{"{FX-1}", 1}, {"{FX-2}", 0}, {"{FX-3}", 1}, {"{FX-4}", 1}};
|
||||
vm.storeSnapshot("{D}", s1);
|
||||
TrackSnapshot s2; // all zeros, empty fx vector
|
||||
vm.storeSnapshot("{A}", s2);
|
||||
@@ -445,7 +454,11 @@ static void testJsonRoundTrip() {
|
||||
CHECK(mm && mm->modeIds.size() == 2 && mm->modeIds.count("mixdown"));
|
||||
const TrackSnapshot* snap = back->snapshot("{D}");
|
||||
CHECK(snap && snap->mainSend == 1 && snap->fxEnable == 0);
|
||||
CHECK(snap && snap->fxOffline.size() == 4 && snap->fxOffline[1] == 0);
|
||||
CHECK(snap && snap->fxOffline.size() == 4 && snap->fxOffline[1].offline == 0);
|
||||
// The FX identities survive the round-trip — without them the restore is
|
||||
// back to guessing at slots.
|
||||
CHECK(snap && snap->fxKeying == FxKeying::Identity);
|
||||
CHECK(snap && snap->fxOffline[3].fxGuid == "{FX-4}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -476,6 +489,9 @@ static void testMalformedJson() {
|
||||
"{\"membership\":[{\"guid\":\"\"}]}", // empty guid
|
||||
"{\"snapshots\":[{\"showInTcp\":1}]}", // snapshot without guid
|
||||
"{\"snapshots\":[{\"guid\":\"x\",\"fxOffline\":[1,notanumber]}]}",
|
||||
"{\"snapshots\":[{\"guid\":\"x\",\"fx\":[{\"guid\":\"{F}\"}]}]}", // fx without offline
|
||||
"{\"snapshots\":[{\"guid\":\"x\",\"fx\":[{\"offline\":1}]}]}", // fx without guid
|
||||
"{\"snapshots\":[{\"guid\":\"x\",\"fx\":[", // truncated fx
|
||||
"{\"modes\":[]}trailing", // trailing garbage
|
||||
};
|
||||
for (const char* j : bad) {
|
||||
@@ -557,7 +573,7 @@ static void testUntaggedLeavesManagedByModeSystem() {
|
||||
// Arrange restores it from that snapshot verbatim, never a hardcoded default.
|
||||
TrackSnapshot snap;
|
||||
snap.showInTcp = 1; snap.showInMixer = 1; snap.mainSend = 0; snap.fxEnable = 1;
|
||||
snap.fxOffline = {0, 1};
|
||||
snap.fxOffline = {{"{FX-A}", 0}, {"{FX-B}", 1}};
|
||||
vm.storeSnapshot("{U1}", snap); // as the shell would, before parking it in Design
|
||||
auto backToArrange = vm.planToggle(tree, kArrangeModeId);
|
||||
const TrackPlan* r = restoreFor(backToArrange, "{U1}");
|
||||
@@ -609,7 +625,8 @@ static void testReconcilePrunesOrphanedSnapshots() {
|
||||
// Two parked tracks (both snapshotted + tagged); {DEL} is about to be deleted.
|
||||
vm.membership().tag("{LIVE}", kDesignModeId);
|
||||
vm.membership().tag("{DEL}", kDesignModeId);
|
||||
TrackSnapshot sLive; sLive.showInTcp = 1; sLive.fxOffline = {0, 1};
|
||||
TrackSnapshot sLive; sLive.showInTcp = 1;
|
||||
sLive.fxOffline = {{"{FX-A}", 0}, {"{FX-B}", 1}};
|
||||
TrackSnapshot sDel; sDel.showInTcp = 1; sDel.fxEnable = 1;
|
||||
vm.storeSnapshot("{LIVE}", sLive);
|
||||
vm.storeSnapshot("{DEL}", sDel);
|
||||
@@ -1739,6 +1756,48 @@ static void testLaneMintingEmptyFolderNotSplit() {
|
||||
|
||||
// -- D2.6 JSON round-trip with lane index + membership -----------------------
|
||||
|
||||
// An EXPLICIT Arrange record is new: the shipped "tag selected tracks -> Arrange"
|
||||
// action untags instead, so until now Arrange was only ever represented by absence.
|
||||
// The render-in-place verb writes one, because the record — not the behaviour — is
|
||||
// what the panel's auto-tag detector defers to. It must be indistinguishable from
|
||||
// absence everywhere else.
|
||||
static void testExplicitArrangeRecordRoundTripsAndBehavesLikeAbsence() {
|
||||
ViewModeModel vm;
|
||||
vm.membership().tag("{TAGGED-ARRANGE}", kArrangeModeId);
|
||||
// "{UNTAGGED}" is deliberately never tagged — the comparison partner.
|
||||
|
||||
const std::string json = vm.serialize();
|
||||
const auto back = ViewModeModel::deserialize(json);
|
||||
CHECK(back.has_value());
|
||||
CHECK(back && *back == vm);
|
||||
if (back) CHECK(back->serialize() == json);
|
||||
|
||||
// The record survives as a record, not collapsed away on the round-trip.
|
||||
if (back) {
|
||||
const Membership* m = back->membership().query("{TAGGED-ARRANGE}");
|
||||
CHECK(m != nullptr);
|
||||
CHECK(m && m->modeIds == std::set<std::string>{kArrangeModeId});
|
||||
CHECK(back->membership().query("{UNTAGGED}") == nullptr);
|
||||
}
|
||||
|
||||
// Membership answers identically for the record and for its absence, in BOTH
|
||||
// modes — that equivalence is what makes writing the record free of behaviour.
|
||||
const auto checkEquivalent = [](const ViewModeModel& m) {
|
||||
CHECK(m.leafBelongsToMode("{TAGGED-ARRANGE}", kArrangeModeId) ==
|
||||
m.leafBelongsToMode("{UNTAGGED}", kArrangeModeId));
|
||||
CHECK(m.leafBelongsToMode("{TAGGED-ARRANGE}", kArrangeModeId));
|
||||
CHECK(m.leafBelongsToMode("{TAGGED-ARRANGE}", kDesignModeId) ==
|
||||
m.leafBelongsToMode("{UNTAGGED}", kDesignModeId));
|
||||
CHECK(!m.leafBelongsToMode("{TAGGED-ARRANGE}", kDesignModeId));
|
||||
};
|
||||
checkEquivalent(vm);
|
||||
if (back) checkEquivalent(*back); // and after a save/reload round-trip
|
||||
|
||||
// untag() still returns it to absence, so the existing way out still works.
|
||||
CHECK(vm.membership().untag("{TAGGED-ARRANGE}"));
|
||||
CHECK(vm.membership().query("{TAGGED-ARRANGE}") == nullptr);
|
||||
}
|
||||
|
||||
static void testLaneJsonRoundTrip() {
|
||||
ViewModeModel vm;
|
||||
CHECK(vm.modes().add(Mode{"mixdown", "Mixdown", 2}));
|
||||
@@ -1806,7 +1865,7 @@ static void testSoloCacheJsonRoundTrip() {
|
||||
ViewModeModel vm;
|
||||
CHECK(vm.modes().add(Mode{"mixdown", "Mixdown", 2}));
|
||||
vm.membership().tag("{T}", kDesignModeId);
|
||||
vm.storeSnapshot("{T}", TrackSnapshot{1, 1, 1, 1, {0, 1}});
|
||||
vm.storeSnapshot("{T}", TrackSnapshot{1, 1, 1, 1, {{"{FX-A}", 0}, {"{FX-B}", 1}}});
|
||||
CHECK(vm.lanes().setManaged("{T}", "lane:0", kArrangeModeId));
|
||||
|
||||
// Every non-zero I_SOLO variant, across more than one mode, plus a GUID that
|
||||
@@ -1882,6 +1941,175 @@ static void testReconcilePrunesTheSoloCacheAlongsideSnapshots() {
|
||||
CHECK(design && design->count("{LIVE}") == 1);
|
||||
}
|
||||
|
||||
// -- Per-FX offline: identity keying and the v1 -> v2 snapshot ladder ---------
|
||||
//
|
||||
// The restore path end-to-end, at the seam the shell actually uses: a stored
|
||||
// snapshot -> planToggle -> makeRestorePlan ops -> resolveFxRestore against the
|
||||
// chain as it stands now. The chain mutations happen while the track is parked,
|
||||
// which is the whole reason a slot cannot be the key.
|
||||
|
||||
namespace {
|
||||
|
||||
// The plan's restore ops for one parked-then-reactivated leaf.
|
||||
std::vector<FxOfflineOp> restoreOpsFor(ViewModeModel& vm, const std::string& guid) {
|
||||
FolderTree tree;
|
||||
tree.nodes.push_back(FolderNode{guid, "", false});
|
||||
const TogglePlan plan = vm.planToggle(tree, kDesignModeId);
|
||||
const TrackPlan* r = restoreFor(plan, guid);
|
||||
return r ? r->fxOffline : std::vector<FxOfflineOp>{};
|
||||
}
|
||||
|
||||
bool writeAt(const FxRestoreResolution& res, int fxIndex, bool offline) {
|
||||
for (const FxOfflineWrite& w : res.writes)
|
||||
if (w.fxIndex == fxIndex) return w.offline == offline;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool anyWriteAt(const FxRestoreResolution& res, int fxIndex) {
|
||||
for (const FxOfflineWrite& w : res.writes)
|
||||
if (w.fxIndex == fxIndex) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// A leaf tagged Design, parked with three identified FX — the state every case
|
||||
// below starts from.
|
||||
ViewModeModel parkedWithThreeFx() {
|
||||
ViewModeModel vm;
|
||||
vm.membership().tag("{T}", kDesignModeId);
|
||||
TrackSnapshot snap;
|
||||
snap.showInTcp = 1; snap.showInMixer = 1; snap.mainSend = 1; snap.fxEnable = 1;
|
||||
snap.fxOffline = {{"{FX-A}", 0}, {"{FX-B}", 1}, {"{FX-C}", 0}};
|
||||
vm.storeSnapshot("{T}", snap);
|
||||
return vm;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
static void testParkReorderRestoreLandsEachPluginItsOwnState() {
|
||||
ViewModeModel vm = parkedWithThreeFx();
|
||||
// Reordered to C, A, B while parked.
|
||||
const FxRestoreResolution res =
|
||||
resolveFxRestore(restoreOpsFor(vm, "{T}"), {"{FX-C}", "{FX-A}", "{FX-B}"});
|
||||
|
||||
CHECK(res.writes.size() == 3);
|
||||
CHECK(res.drops.total() == 0);
|
||||
CHECK(writeAt(res, 1, false)); // A
|
||||
CHECK(writeAt(res, 2, true)); // B's captured offline followed B, not slot 1
|
||||
CHECK(writeAt(res, 0, false)); // C
|
||||
}
|
||||
|
||||
static void testParkDeleteFxRestoreDropsExplicitly() {
|
||||
ViewModeModel vm = parkedWithThreeFx();
|
||||
// B deleted while parked.
|
||||
const FxRestoreResolution res =
|
||||
resolveFxRestore(restoreOpsFor(vm, "{T}"), {"{FX-A}", "{FX-C}"});
|
||||
|
||||
CHECK(res.writes.size() == 2);
|
||||
CHECK(res.drops.missingIdentity == 1);
|
||||
CHECK(writeAt(res, 0, false)); // A
|
||||
CHECK(writeAt(res, 1, false)); // C — and NOT B's captured `true`
|
||||
CHECK(!describeFxRestoreDrops(res.drops, 1).empty());
|
||||
}
|
||||
|
||||
static void testParkAddFxRestoreLeavesItAlone() {
|
||||
ViewModeModel vm = parkedWithThreeFx();
|
||||
// A new plugin inserted at the head while parked.
|
||||
const FxRestoreResolution res = resolveFxRestore(
|
||||
restoreOpsFor(vm, "{T}"), {"{FX-NEW}", "{FX-A}", "{FX-B}", "{FX-C}"});
|
||||
|
||||
CHECK(res.writes.size() == 3);
|
||||
CHECK(res.drops.total() == 0);
|
||||
CHECK(!anyWriteAt(res, 0)); // the added FX is never written
|
||||
CHECK(writeAt(res, 1, false));
|
||||
CHECK(writeAt(res, 2, true));
|
||||
CHECK(writeAt(res, 3, false));
|
||||
}
|
||||
|
||||
static void testV2WritesTheLegacySlotArrayBesideIdentities() {
|
||||
// The downgrade half of the ladder: a build that predates identity keying
|
||||
// reads "fxOffline" and skips "fx", so it keeps exactly the behavior it had
|
||||
// instead of losing every captured FX state to an unknown key.
|
||||
ViewModeModel vm = parkedWithThreeFx();
|
||||
const std::string json = vm.serialize();
|
||||
|
||||
CHECK(json.find("\"fxOffline\":[0,1,0]") != std::string::npos);
|
||||
CHECK(json.find("\"fx\":[{\"guid\":\"{FX-A}\",\"offline\":0},"
|
||||
"{\"guid\":\"{FX-B}\",\"offline\":1},"
|
||||
"{\"guid\":\"{FX-C}\",\"offline\":0}]") != std::string::npos);
|
||||
|
||||
auto back = ViewModeModel::deserialize(json);
|
||||
CHECK(back.has_value());
|
||||
CHECK(back && *back == vm);
|
||||
|
||||
// The downgrade path itself, at the only point it can be reached from here:
|
||||
// an unknown array-of-objects key beside "fxOffline" is skipped and the slot
|
||||
// array is still read — the same skipValue branch an older build takes on
|
||||
// "fx". (An actual older binary is not runnable from this test.)
|
||||
auto asOlder = ViewModeModel::deserialize(
|
||||
"{\"snapshots\":[{\"guid\":\"{T}\",\"fxOffline\":[0,1,0],"
|
||||
"\"futureKey\":[{\"guid\":\"{FX-A}\",\"offline\":0}]}]}");
|
||||
CHECK(asOlder.has_value());
|
||||
CHECK(asOlder && asOlder->snapshot("{T}") &&
|
||||
asOlder->snapshot("{T}")->fxOffline.size() == 3);
|
||||
CHECK(asOlder && asOlder->snapshot("{T}") &&
|
||||
asOlder->snapshot("{T}")->fxOffline[1].offline == 1);
|
||||
}
|
||||
|
||||
static void testLegacyBlobLiftsToSlotKeyingAndStillRestores() {
|
||||
// A view_state written before FX identity existed: no "fx" key anywhere.
|
||||
const char* v1 =
|
||||
"{\"version\":1,\"activeMode\":\"arrange\",\"modes\":[{\"id\":\"arrange\","
|
||||
"\"displayName\":\"Arrange\",\"ordinal\":0},{\"id\":\"design\","
|
||||
"\"displayName\":\"Design\",\"ordinal\":1}],\"membership\":[{\"guid\":\"{T}\","
|
||||
"\"modes\":[\"design\"],\"showBoth\":false}],\"snapshots\":[{\"guid\":\"{T}\","
|
||||
"\"showInTcp\":1,\"showInMixer\":1,\"mainSend\":1,\"fxEnable\":1,"
|
||||
"\"fxOffline\":[0,1,0]}],\"lanes\":[]}";
|
||||
|
||||
auto loaded = ViewModeModel::deserialize(v1);
|
||||
CHECK(loaded.has_value());
|
||||
if (!loaded) return;
|
||||
|
||||
const TrackSnapshot* snap = loaded->snapshot("{T}");
|
||||
CHECK(snap != nullptr);
|
||||
CHECK(snap && snap->fxKeying == FxKeying::Slot); // no identities to key on
|
||||
CHECK(snap && snap->fxOffline.size() == 3);
|
||||
CHECK(snap && snap->fxOffline[1].offline == 1);
|
||||
CHECK(snap && snap->fxOffline[1].fxGuid.empty());
|
||||
CHECK(snap && snap->showInTcp == 1 && snap->mainSend == 1);
|
||||
|
||||
// It still restores — by position, which is all its bytes can support, and
|
||||
// is exactly what the pre-change build would have done with them.
|
||||
const std::vector<FxOfflineOp> ops = restoreOpsFor(*loaded, "{T}");
|
||||
CHECK(ops.size() == 3);
|
||||
CHECK(!ops.empty() && ops[0].keying == FxKeying::Slot);
|
||||
const FxRestoreResolution res = resolveFxRestore(ops, {"{FX-X}", "{FX-Y}", "{FX-Z}"});
|
||||
CHECK(res.writes.size() == 3);
|
||||
CHECK(res.drops.total() == 0);
|
||||
CHECK(writeAt(res, 0, false));
|
||||
CHECK(writeAt(res, 1, true));
|
||||
CHECK(writeAt(res, 2, false));
|
||||
|
||||
// Re-saving a lifted snapshot does NOT invent identities for it: the "fx"
|
||||
// key stays absent, and a second load reads the same slot-keyed shape.
|
||||
const std::string resaved = loaded->serialize();
|
||||
CHECK(resaved.find("\"fx\":") == std::string::npos);
|
||||
CHECK(resaved.find("\"fxOffline\":[0,1,0]") != std::string::npos);
|
||||
auto again = ViewModeModel::deserialize(resaved);
|
||||
CHECK(again.has_value());
|
||||
CHECK(again && *again == *loaded);
|
||||
CHECK(again && again->snapshot("{T}") &&
|
||||
again->snapshot("{T}")->fxKeying == FxKeying::Slot);
|
||||
|
||||
// And the lift is one-shot: a restore consumes the snapshot, so the next park
|
||||
// captures identities and the project leaves the legacy shape behind.
|
||||
loaded->clearSnapshot("{T}");
|
||||
TrackSnapshot fresh;
|
||||
fresh.fxOffline = {{"{FX-X}", 1}};
|
||||
loaded->storeSnapshot("{T}", fresh);
|
||||
CHECK(loaded->serialize().find("\"fx\":[{\"guid\":\"{FX-X}\",\"offline\":1}]")
|
||||
!= std::string::npos);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testSerializeGoldenLiteral();
|
||||
testNModeRegistryAndMembership();
|
||||
@@ -1922,6 +2150,7 @@ int main() {
|
||||
testLaneMintingShowBothNotForceSplit();
|
||||
testLaneMintingSingleModeLeafVisibleOnceNoSplit();
|
||||
testLaneMintingEmptyFolderNotSplit();
|
||||
testExplicitArrangeRecordRoundTripsAndBehavesLikeAbsence();
|
||||
testLaneJsonRoundTrip();
|
||||
testLaneMalformedJson();
|
||||
|
||||
@@ -1931,6 +2160,13 @@ int main() {
|
||||
testSoloCacheMalformedJson();
|
||||
testReconcilePrunesTheSoloCacheAlongsideSnapshots();
|
||||
|
||||
// Per-FX offline identity keying + the v1 -> v2 snapshot ladder
|
||||
testParkReorderRestoreLandsEachPluginItsOwnState();
|
||||
testParkDeleteFxRestoreDropsExplicitly();
|
||||
testParkAddFxRestoreLeavesItAlone();
|
||||
testV2WritesTheLegacySlotArrayBesideIdentities();
|
||||
testLegacyBlobLiftsToSlotKeyingAndStillRestores();
|
||||
|
||||
if (g_fail == 0) std::printf("All tests passed.\n");
|
||||
return g_fail ? 1 : 0;
|
||||
}
|
||||
|
||||
+121
-1
@@ -14,7 +14,8 @@
|
||||
// contentHash values against a silent feed-sequence drift); and the lossless mono
|
||||
// collapse (bit-identical N-channel fold, the one-sample-differs and signed-zero
|
||||
// declines, already-mono, zero/single-frame, an odd padded leading chunk, and the
|
||||
// content-hash consequence).
|
||||
// content-hash consequence) plus its buffer-side wrapper applyMonoCollapse (the
|
||||
// bytes/layout pairing, the byte-identical decline, one-ULP, and double-apply).
|
||||
|
||||
#include "../src/core/capture/wav_codec.h"
|
||||
|
||||
@@ -795,6 +796,119 @@ static void testCollapseOutcomeSuffixesAreDistinctStrings() {
|
||||
CHECK(collapsed.find("failed") == std::string::npos);
|
||||
}
|
||||
|
||||
// --- applyMonoCollapse: the buffer-side collapse a bake landing takes ---------
|
||||
|
||||
// A dead-center instrument render is dual-mono, and must land 1-channel: the returned
|
||||
// layout says one channel, and it is the parse OF the returned bytes, so the caller's
|
||||
// channelCount, its hash and the file it writes cannot come from different buffers.
|
||||
static void testApplyCollapseDualMonoLandsOneChannel() {
|
||||
auto wav = buildFloatWav(2, 48000, 6,
|
||||
[](std::size_t f, std::uint16_t) {
|
||||
return 0.25f * static_cast<float>(f) - 0.5f;
|
||||
});
|
||||
const CollapsedWav staged = applyMonoCollapse(wav);
|
||||
CHECK(staged.collapsed);
|
||||
CHECK(staged.layout.valid);
|
||||
CHECK(staged.layout.channelCount == 1);
|
||||
|
||||
const WavLayout reparsed = parseWavLayout(staged.bytes);
|
||||
CHECK(reparsed.valid);
|
||||
CHECK(reparsed.channelCount == staged.layout.channelCount);
|
||||
CHECK(reparsed.sampleRate == staged.layout.sampleRate);
|
||||
CHECK(reparsed.frameCount() == staged.layout.frameCount());
|
||||
CHECK(reparsed.dataByteOffset == staged.layout.dataByteOffset);
|
||||
CHECK(reparsed.dataByteLength == staged.layout.dataByteLength);
|
||||
|
||||
const auto pcm = extractFloatFrames(staged.bytes, staged.layout, 0, 6);
|
||||
CHECK(pcm.size() == 6);
|
||||
for (std::size_t f = 0; f < 6 && f < pcm.size(); ++f)
|
||||
CHECK(pcm[f] == 0.25f * static_cast<float>(f) - 0.5f);
|
||||
}
|
||||
|
||||
// A true-stereo bake must land exactly the bytes it staged — this is the regression the
|
||||
// collapse must not cause, so it is asserted on the bytes themselves, not on the verdict.
|
||||
static void testApplyCollapseTrueStereoIsByteIdentical() {
|
||||
auto wav = buildFloatWav(2, 48000, 5,
|
||||
[](std::size_t f, std::uint16_t ch) {
|
||||
return ch == 0 ? static_cast<float>(f)
|
||||
: -static_cast<float>(f);
|
||||
});
|
||||
const CollapsedWav staged = applyMonoCollapse(wav);
|
||||
CHECK(!staged.collapsed);
|
||||
CHECK(staged.bytes == wav);
|
||||
CHECK(staged.layout.channelCount == 2);
|
||||
CHECK(staged.layout.frameCount() == 5);
|
||||
// The dedup key a declined bake writes is the one it would have written before the
|
||||
// collapse existed.
|
||||
CHECK(hashWavContent(staged.bytes) == hashWavContent(wav));
|
||||
}
|
||||
|
||||
// One float ULP apart in ONE sample is a difference, not an epsilon: the buffer path
|
||||
// must decline it exactly as the predicate does, and hand the bytes back untouched.
|
||||
static void testApplyCollapseDeclinesOnOneUlpDifference() {
|
||||
auto wav = buildFloatWav(2, 48000, 8,
|
||||
[](std::size_t f, std::uint16_t ch) {
|
||||
float v = 1.0f + static_cast<float>(f);
|
||||
if (f == 4 && ch == 1) v = nextafterf(v, 2.0f);
|
||||
return v;
|
||||
});
|
||||
const CollapsedWav staged = applyMonoCollapse(wav);
|
||||
CHECK(!staged.collapsed);
|
||||
CHECK(staged.bytes == wav);
|
||||
CHECK(staged.layout.channelCount == 2);
|
||||
}
|
||||
|
||||
// A sound that was already mono comes back untouched, and a collapsed buffer fed back
|
||||
// through does not collapse a second time (the rebuild would otherwise re-hash).
|
||||
static void testApplyCollapseAlreadyMonoIsUntouched() {
|
||||
auto mono = buildFloatWav(1, 44100, 4,
|
||||
[](std::size_t f, std::uint16_t) {
|
||||
return static_cast<float>(f);
|
||||
});
|
||||
const CollapsedWav staged = applyMonoCollapse(mono);
|
||||
CHECK(!staged.collapsed);
|
||||
CHECK(staged.bytes == mono);
|
||||
CHECK(staged.layout.channelCount == 1);
|
||||
|
||||
auto dual = buildFloatWav(2, 44100, 4,
|
||||
[](std::size_t f, std::uint16_t) {
|
||||
return static_cast<float>(f);
|
||||
});
|
||||
const CollapsedWav once = applyMonoCollapse(dual);
|
||||
CHECK(once.collapsed);
|
||||
const CollapsedWav twice = applyMonoCollapse(once.bytes);
|
||||
CHECK(!twice.collapsed);
|
||||
CHECK(twice.bytes == once.bytes);
|
||||
}
|
||||
|
||||
// The collapse is permitted only because it is lossless: frame count, sample rate and
|
||||
// bit depth survive it, and only the interleave stride changes.
|
||||
static void testApplyCollapsePreservesFramesRateAndDepth() {
|
||||
auto wav = buildFloatWav(2, 44100, 7,
|
||||
[](std::size_t f, std::uint16_t) {
|
||||
return 0.5f - 0.125f * static_cast<float>(f);
|
||||
});
|
||||
const WavLayout before = parseWavLayout(wav);
|
||||
const CollapsedWav staged = applyMonoCollapse(wav);
|
||||
CHECK(staged.collapsed);
|
||||
CHECK(staged.layout.frameCount() == before.frameCount());
|
||||
CHECK(staged.layout.sampleRate == before.sampleRate);
|
||||
// `valid` implies 32-bit float (the parser accepts nothing else), and 4 bytes per
|
||||
// frame at one channel is that depth spelled out in the data chunk's own length.
|
||||
CHECK(staged.layout.valid);
|
||||
CHECK(staged.layout.dataByteLength == before.frameCount() * 4u);
|
||||
}
|
||||
|
||||
// Bytes that never parsed keep `collapsed` false AND `layout.valid` false — the pair a
|
||||
// caller refuses on, and the reason an invalid layout can only mean a bad INPUT.
|
||||
static void testApplyCollapseUnparseableInputIsReportedInvalid() {
|
||||
std::vector<std::uint8_t> junk = {'N','O','P','E', 0,0,0,0, 'W','A','V','E'};
|
||||
const CollapsedWav staged = applyMonoCollapse(junk);
|
||||
CHECK(!staged.collapsed);
|
||||
CHECK(!staged.layout.valid);
|
||||
CHECK(staged.bytes == junk);
|
||||
}
|
||||
|
||||
int main() {
|
||||
testParseCanonicalStereo();
|
||||
testParseMonoAndLeadingChunk();
|
||||
@@ -831,6 +945,12 @@ int main() {
|
||||
testCollapseChangesContentHash();
|
||||
testCollapsePreservesQuietNaNBitPattern();
|
||||
testCollapseOutcomeSuffixesAreDistinctStrings();
|
||||
testApplyCollapseDualMonoLandsOneChannel();
|
||||
testApplyCollapseTrueStereoIsByteIdentical();
|
||||
testApplyCollapseDeclinesOnOneUlpDifference();
|
||||
testApplyCollapseAlreadyMonoIsUntouched();
|
||||
testApplyCollapsePreservesFramesRateAndDepth();
|
||||
testApplyCollapseUnparseableInputIsReportedInvalid();
|
||||
|
||||
if (g_fail == 0) std::printf("wav_codec: all tests passed\n");
|
||||
else std::printf("wav_codec: %d CHECK(s) FAILED\n", g_fail);
|
||||
|
||||
Reference in New Issue
Block a user