fix(capture): populate contentHash on captured samples to fix always-prompting remove

contentHash was left empty on every capture; hashReferencedElsewhere returns
false for empty hashes, so every remove looked like a last reference. Add
FNV-1a hashBytes to capture_paths, wire into both commit paths. NOTE: this
activates index dedup-by-hash in production — a bit-identical re-capture now
collapses onto the existing entry instead of adding a duplicate (spec-intended).
This commit is contained in:
2026-07-26 16:37:46 -04:00
parent 791a9c60eb
commit 5fabade90c
6 changed files with 133 additions and 5 deletions
+58
View File
@@ -5,8 +5,10 @@
#include "../src/capture_paths.h"
#include <cstdint>
#include <cstdio>
#include <string>
#include <vector>
using namespace reasampler;
@@ -293,6 +295,57 @@ static void testTransitionInPlaceSaveIsNoOp() {
== ProjectTransition::NoOp);
}
// --- hashBytes (FNV-1a content hash) ----------------------------------------
//
// The fix for the confirm-on-last-reference bug: hashBytes produces a 16-char hex
// string that capture.cpp and capture_realtime.cpp store on Sample::contentHash so
// BankBook::hashReferencedElsewhere can detect copies and suppress the confirm when
// another bank still holds the same file.
static void testHashBytesOutputFormat() {
// Output is always 16 lowercase hex characters.
const std::uint8_t bytes[] = {0x01, 0x02, 0x03};
const std::string h = hashBytes(bytes, 3);
CHECK(h.size() == 16);
for (char c : h) {
CHECK((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f'));
}
}
static void testHashBytesDeterministic() {
// Same input always produces the same output (bit-identical captures get
// the same hash, so hashReferencedElsewhere fires correctly for copies).
const std::uint8_t bytes[] = {0xDE, 0xAD, 0xBE, 0xEF, 0x01};
CHECK(hashBytes(bytes, 5) == hashBytes(bytes, 5));
}
static void testHashBytesDistinct() {
// Different inputs produce different hashes (no accidental dedup of distinct
// files). This covers the "one-bit-flip changes the hash" property.
std::uint8_t a[] = {0x00, 0x00};
std::uint8_t b[] = {0x00, 0x01};
CHECK(hashBytes(a, 2) != hashBytes(b, 2));
std::uint8_t c[] = {0xFF, 0xFF, 0xFF};
std::uint8_t d[] = {0xFF, 0xFF, 0xFE};
CHECK(hashBytes(c, 3) != hashBytes(d, 3));
}
static void testHashBytesEmptyBufferIsNonEmpty() {
// An empty buffer returns the FNV-1a offset basis in hex (stable, non-empty
// sentinel) — capturing the contract that even empty inputs yield a 16-char hash.
const std::string h = hashBytes(nullptr, 0);
CHECK(h.size() == 16);
}
static void testHashBytesLargerBufferDiffersFromSmaller() {
// Padding a buffer with a zero byte must change the hash (order + length
// sensitivity so two differently-sized WAV files don't accidentally collide).
const std::uint8_t short_buf[] = {0xAB, 0xCD};
const std::uint8_t long_buf[] = {0xAB, 0xCD, 0x00};
CHECK(hashBytes(short_buf, 2) != hashBytes(long_buf, 3));
}
int main() {
testNormalizeSlashes();
testSanitizeStem();
@@ -318,6 +371,11 @@ int main() {
testTransitionTwoUnsavedProjectsSwitchLoads();
testTransitionFirstSaveOfUnsavedRelocatesButPlanNoOps();
testTransitionInPlaceSaveIsNoOp();
testHashBytesOutputFormat();
testHashBytesDeterministic();
testHashBytesDistinct();
testHashBytesEmptyBufferIsNonEmpty();
testHashBytesLargerBufferDiffersFromSmaller();
if (g_fail == 0) std::printf("capture_paths: all tests passed\n");
else std::printf("capture_paths: %d CHECK(s) FAILED\n", g_fail);