diff --git a/CMakeLists.txt b/CMakeLists.txt index ae0cf63..f996053 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -77,6 +77,10 @@ endif() if((SPC_ENABLE_SANITIZERS OR SPC_ENABLE_THREAD_SANITIZER OR SPC_ENABLE_COVERAGE) AND MSVC) message(FATAL_ERROR "Sanitizer and coverage options support GCC and Clang only") endif() +if(SPC_BUILD_BENCHMARKS AND (SPC_ENABLE_SANITIZERS OR SPC_ENABLE_THREAD_SANITIZER)) + message(FATAL_ERROR "SPC_BUILD_BENCHMARKS cannot be combined with sanitizers: the " + "benchmarks replace operator new and delete, as the sanitizer runtimes do") +endif() if(SPC_ENABLE_SANITIZERS) add_compile_options(-fsanitize=address,undefined -fno-omit-frame-pointer) add_link_options(-fsanitize=address,undefined) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 70a4b99..752bd18 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -138,6 +138,10 @@ python3 build-bench/_deps/benchmark-src/tools/compare.py benchmarks \ build-bench/main.json build-bench/branch.json ``` +Under Valgrind, pass `--soname-synonyms=somalloc=nouserintercepts`, or +Valgrind's own operator new replaces the counting one and the suite exits +with an error. + ## Pull requests - Branch from `main` and open a pull request against it. Every CI job must diff --git a/Makefile b/Makefile index f2e5dfa..1c9473f 100644 --- a/Makefile +++ b/Makefile @@ -54,8 +54,9 @@ tidy: # Release build in build-bench, without tests and examples. bench: - @$(MAKE) --no-print-directory build BUILD_DIR=build-bench \ - CMAKE_ARGS="-DSPC_BUILD_BENCHMARKS=ON -DSPC_BUILD_TESTS=OFF -DSPC_BUILD_EXAMPLES=OFF $(CMAKE_ARGS)" + @$(CMAKE) -S . -B build-bench -DCMAKE_BUILD_TYPE=Release -DSPC_BUILD_BENCHMARKS=ON \ + -DSPC_BUILD_TESTS=OFF -DSPC_BUILD_EXAMPLES=OFF $(CMAKE_ARGS) + @$(CMAKE) --build build-bench -j$(NPROC) @./build-bench/benchmarks/spc_benchmarks $(BENCH_ARGS) coverage: diff --git a/benchmarks/benchmarks.cpp b/benchmarks/benchmarks.cpp index 3c4d71b..ac72601 100644 --- a/benchmarks/benchmarks.cpp +++ b/benchmarks/benchmarks.cpp @@ -21,11 +21,14 @@ #include #include #include +#include #include #include #include #include +#include #include +#include #include #include #include @@ -295,10 +298,40 @@ void register_benchmarks() { }); } +/// Whether the allocation counter sees an operator new call made here and one +/// made inside the standard library. When it does not, the heap counters would +/// read low without failing, so this prints the likely cause. +bool allocation_counter_works() { + const spc::test::AllocationProbe probe; + void* block = ::operator new(64); + benchmark::DoNotOptimize(block); + ::operator delete(block); + const std::uint64_t direct = probe.stats().allocations; + const std::runtime_error error("a message the standard library copies to the heap"); + benchmark::DoNotOptimize(error.what()); + const std::uint64_t library = probe.stats().allocations - direct; + if (direct != 1) { + std::fputs("The allocation counter misses operator new calls. Under Valgrind, run with " + "--soname-synonyms=somalloc=nouserintercepts.\n", + stderr); + return false; + } + if (library == 0) { + std::fputs("The allocation counter misses allocations made inside the standard library, " + "which happens when LTO internalizes the replacement operator new.\n", + stderr); + return false; + } + return true; +} + } // namespace int main(int argc, char** argv) { benchmark::MaybeReenterWithoutASLR(argc, argv); + if (!allocation_counter_works()) { + return 1; + } register_benchmarks(); benchmark::Initialize(&argc, argv); if (benchmark::ReportUnrecognizedArguments(argc, argv)) { diff --git a/tests/support/allocation_counter.cpp b/tests/support/allocation_counter.cpp index e5c9174..a086dfe 100644 --- a/tests/support/allocation_counter.cpp +++ b/tests/support/allocation_counter.cpp @@ -20,7 +20,8 @@ namespace { struct Counters { - std::atomic active{false}; + std::atomic owned{false}; // a probe exists + std::atomic active{false}; // allocations are being counted std::atomic allocations{0}; std::atomic bytes{0}; std::atomic live{0}; @@ -118,17 +119,20 @@ std::size_t to_size(std::align_val_t alignment) noexcept { namespace spc::test { AllocationProbe::AllocationProbe() { - if (counters.active.exchange(true)) { + if (counters.owned.exchange(true)) { throw std::logic_error("another AllocationProbe is active"); } + // Zero before counting starts, so no allocation is counted and then wiped. counters.allocations.store(0); counters.bytes.store(0); counters.live.store(0); counters.peak.store(0); + counters.active.store(true); } AllocationProbe::~AllocationProbe() { counters.active.store(false); + counters.owned.store(false); } AllocationStats AllocationProbe::stats() const noexcept { @@ -142,86 +146,96 @@ AllocationStats AllocationProbe::stats() const noexcept { } // namespace spc::test -void* operator new(std::size_t size) { +// LTO may internalize the replacements (ld64 does), and then allocations made +// inside a shared standard library bypass them. This keeps them exported. +#if defined(__GNUC__) +#define SPC_REPLACEMENT __attribute__((used, visibility("default"))) +#else +#define SPC_REPLACEMENT +#endif + +SPC_REPLACEMENT void* operator new(std::size_t size) { return allocate(size, 0); } -void* operator new[](std::size_t size) { +SPC_REPLACEMENT void* operator new[](std::size_t size) { return allocate(size, 0); } -void* operator new(std::size_t size, std::align_val_t alignment) { +SPC_REPLACEMENT void* operator new(std::size_t size, std::align_val_t alignment) { return allocate(size, to_size(alignment)); } -void* operator new[](std::size_t size, std::align_val_t alignment) { +SPC_REPLACEMENT void* operator new[](std::size_t size, std::align_val_t alignment) { return allocate(size, to_size(alignment)); } -void* operator new(std::size_t size, const std::nothrow_t& /*tag*/) noexcept { +SPC_REPLACEMENT void* operator new(std::size_t size, const std::nothrow_t& /*tag*/) noexcept { return allocate_nothrow(size, 0); } -void* operator new[](std::size_t size, const std::nothrow_t& /*tag*/) noexcept { +SPC_REPLACEMENT void* operator new[](std::size_t size, const std::nothrow_t& /*tag*/) noexcept { return allocate_nothrow(size, 0); } -void* operator new(std::size_t size, std::align_val_t alignment, - const std::nothrow_t& /*tag*/) noexcept { +SPC_REPLACEMENT void* operator new(std::size_t size, std::align_val_t alignment, + const std::nothrow_t& /*tag*/) noexcept { return allocate_nothrow(size, to_size(alignment)); } -void* operator new[](std::size_t size, std::align_val_t alignment, - const std::nothrow_t& /*tag*/) noexcept { +SPC_REPLACEMENT void* operator new[](std::size_t size, std::align_val_t alignment, + const std::nothrow_t& /*tag*/) noexcept { return allocate_nothrow(size, to_size(alignment)); } -void operator delete(void* ptr) noexcept { +SPC_REPLACEMENT void operator delete(void* ptr) noexcept { deallocate(ptr, 0); } -void operator delete[](void* ptr) noexcept { +SPC_REPLACEMENT void operator delete[](void* ptr) noexcept { deallocate(ptr, 0); } -void operator delete(void* ptr, std::size_t /*size*/) noexcept { +SPC_REPLACEMENT void operator delete(void* ptr, std::size_t /*size*/) noexcept { deallocate(ptr, 0); } -void operator delete[](void* ptr, std::size_t /*size*/) noexcept { +SPC_REPLACEMENT void operator delete[](void* ptr, std::size_t /*size*/) noexcept { deallocate(ptr, 0); } -void operator delete(void* ptr, std::align_val_t alignment) noexcept { +SPC_REPLACEMENT void operator delete(void* ptr, std::align_val_t alignment) noexcept { deallocate(ptr, to_size(alignment)); } -void operator delete[](void* ptr, std::align_val_t alignment) noexcept { +SPC_REPLACEMENT void operator delete[](void* ptr, std::align_val_t alignment) noexcept { deallocate(ptr, to_size(alignment)); } -void operator delete(void* ptr, std::size_t /*size*/, std::align_val_t alignment) noexcept { +SPC_REPLACEMENT void operator delete(void* ptr, std::size_t /*size*/, + std::align_val_t alignment) noexcept { deallocate(ptr, to_size(alignment)); } -void operator delete[](void* ptr, std::size_t /*size*/, std::align_val_t alignment) noexcept { +SPC_REPLACEMENT void operator delete[](void* ptr, std::size_t /*size*/, + std::align_val_t alignment) noexcept { deallocate(ptr, to_size(alignment)); } -void operator delete(void* ptr, const std::nothrow_t& /*tag*/) noexcept { +SPC_REPLACEMENT void operator delete(void* ptr, const std::nothrow_t& /*tag*/) noexcept { deallocate(ptr, 0); } -void operator delete[](void* ptr, const std::nothrow_t& /*tag*/) noexcept { +SPC_REPLACEMENT void operator delete[](void* ptr, const std::nothrow_t& /*tag*/) noexcept { deallocate(ptr, 0); } -void operator delete(void* ptr, std::align_val_t alignment, - const std::nothrow_t& /*tag*/) noexcept { +SPC_REPLACEMENT void operator delete(void* ptr, std::align_val_t alignment, + const std::nothrow_t& /*tag*/) noexcept { deallocate(ptr, to_size(alignment)); } -void operator delete[](void* ptr, std::align_val_t alignment, - const std::nothrow_t& /*tag*/) noexcept { +SPC_REPLACEMENT void operator delete[](void* ptr, std::align_val_t alignment, + const std::nothrow_t& /*tag*/) noexcept { deallocate(ptr, to_size(alignment)); } diff --git a/tests/support/payload_text.hpp b/tests/support/payload_text.hpp index 88dae98..3ac42dd 100644 --- a/tests/support/payload_text.hpp +++ b/tests/support/payload_text.hpp @@ -5,6 +5,9 @@ /// declaration order and its rings last. The text is the same on every /// standard library: strings are quoted and escaped to printable ASCII, and /// doubles use std::format's shortest round-trip form. +/// +/// Each renderer destructures its struct, so a new field breaks the build +/// until it is printed here too. #pragma once @@ -48,7 +51,8 @@ inline std::string rings_text(const std::vector& rings) { for (std::size_t r = 0; r < rings.size(); ++r) { out += r == 0 ? "(" : ", ("; for (std::size_t p = 0; p < rings[r].size(); ++p) { - out += std::format("{}{} {}", p == 0 ? "" : ", ", rings[r][p].lon, rings[r][p].lat); + const auto& [lon, lat] = rings[r][p]; + out += std::format("{}{} {}", p == 0 ? "" : ", ", lon, lat); } out += ')'; } @@ -69,98 +73,109 @@ inline std::string_view layer_name(FireWeatherLayer layer) { } inline std::string to_text(const CategoricalOutlookPayload& payload) { + const auto& [day_offset, features] = payload; std::string out = std::format("CategoricalOutlookPayload day_offset={} features={}\n", - payload.day_offset, payload.features.size()); - for (const OutlookFeature& f : payload.features) { + day_offset, features.size()); + for (const auto& [label, severity, rings, issued_at, valid_from, valid_until] : features) { out += std::format("label={} severity={} issued_at={} valid_from={} valid_until={} rings={}\n", - quote(f.label), int{f.severity}, quote(f.issued_at), quote(f.valid_from), - quote(f.valid_until), rings_text(f.rings)); + quote(label), int{severity}, quote(issued_at), quote(valid_from), + quote(valid_until), rings_text(rings)); } return out; } inline std::string to_text(const ProbOutlookPayload& payload) { - std::string out = - std::format("ProbOutlookPayload day_offset={} hazard={} features={}\n", payload.day_offset, - quote(payload.hazard), payload.features.size()); - for (const ProbOutlookFeature& f : payload.features) { + const auto& [day_offset, payload_hazard, features] = payload; + std::string out = std::format("ProbOutlookPayload day_offset={} hazard={} features={}\n", + day_offset, quote(payload_hazard), features.size()); + for (const auto& [hazard, probability, rings, issued_at, valid_from, valid_until] : features) { out += std::format( "hazard={} probability={} issued_at={} valid_from={} valid_until={} rings={}\n", - quote(f.hazard), f.probability, quote(f.issued_at), quote(f.valid_from), - quote(f.valid_until), rings_text(f.rings)); + quote(hazard), probability, quote(issued_at), quote(valid_from), quote(valid_until), + rings_text(rings)); } return out; } inline std::string to_text(const ConditionalIntensityPayload& payload) { - std::string out = std::format("ConditionalIntensityPayload day={} hazard={} features={}\n", - payload.day, quote(payload.hazard), payload.features.size()); - for (const ConditionalIntensityFeature& f : payload.features) { + const auto& [day, hazard, features] = payload; + std::string out = std::format("ConditionalIntensityPayload day={} hazard={} features={}\n", day, + quote(hazard), features.size()); + for (const auto& [label, cig_level, rings, issued_at, valid_from, valid_until] : features) { out += std::format( "label={} cig_level={} issued_at={} valid_from={} valid_until={} rings={}\n", - quote(f.label), int{f.cig_level}, quote(f.issued_at), quote(f.valid_from), - quote(f.valid_until), rings_text(f.rings)); + quote(label), int{cig_level}, quote(issued_at), quote(valid_from), quote(valid_until), + rings_text(rings)); } return out; } inline std::string to_text(const Day48OutlookPayload& payload) { - std::string out = std::format("Day48OutlookPayload day={} features={}\n", payload.day, - payload.features.size()); - for (const Day48Feature& f : payload.features) { + const auto& [payload_day, features] = payload; + std::string out = + std::format("Day48OutlookPayload day={} features={}\n", payload_day, features.size()); + for (const auto& [day, label, probability, rings, issued_at, valid_from, valid_until] : + features) { out += std::format( "day={} label={} probability={} issued_at={} valid_from={} valid_until={} rings={}\n", - f.day, quote(f.label), f.probability, quote(f.issued_at), quote(f.valid_from), - quote(f.valid_until), rings_text(f.rings)); + day, quote(label), probability, quote(issued_at), quote(valid_from), quote(valid_until), + rings_text(rings)); } return out; } inline std::string to_text(const FireWeatherPayload& payload) { - std::string out = std::format("FireWeatherPayload day={} features={}\n", payload.day, - payload.features.size()); - for (const FireWeatherFeature& f : payload.features) { - out += std::format("day={} layer={} label={} severity={} probability={} issued_at={} " - "valid_from={} valid_until={} rings={}\n", - f.day, layer_name(f.layer), quote(f.label), int{f.severity}, - f.probability, quote(f.issued_at), quote(f.valid_from), - quote(f.valid_until), rings_text(f.rings)); + const auto& [payload_day, features] = payload; + std::string out = + std::format("FireWeatherPayload day={} features={}\n", payload_day, features.size()); + for (const auto& [day, layer, label, severity, probability, rings, issued_at, valid_from, + valid_until] : features) { + out += + std::format("day={} layer={} label={} severity={} probability={} issued_at={} " + "valid_from={} valid_until={} rings={}\n", + day, layer_name(layer), quote(label), int{severity}, probability, + quote(issued_at), quote(valid_from), quote(valid_until), rings_text(rings)); } return out; } inline std::string to_text(const MesoscalePayload& payload) { - std::string out = std::format("MesoscalePayload discussions={}\n", payload.discussions.size()); - for (const MesoscaleDiscussion& d : payload.discussions) { - out += std::format("name={} folder_path={} url={} rings={}\n", quote(d.name), - quote(d.folder_path), quote(d.url), rings_text(d.rings)); + const auto& [discussions] = payload; + std::string out = std::format("MesoscalePayload discussions={}\n", discussions.size()); + for (const auto& [name, folder_path, url, rings] : discussions) { + out += std::format("name={} folder_path={} url={} rings={}\n", quote(name), + quote(folder_path), quote(url), rings_text(rings)); } return out; } inline std::string to_text(const WatchPayload& payload) { - std::string out = std::format("WatchPayload watches={}\n", payload.watches.size()); - for (const Watch& w : payload.watches) { + const auto& [watches] = payload; + std::string out = std::format("WatchPayload watches={}\n", watches.size()); + for (const auto& [number, year, type, sel, is_pds, max_hail_size, max_wind_gust_knots, spc_url, + issued_at, expires_at, rings] : watches) { out += std::format("number={} year={} type={} sel={} is_pds={} max_hail_size={} " "max_wind_gust_knots={} spc_url={} issued_at={} expires_at={} " "rings={}\n", - w.number, w.year, quote(w.type), quote(w.sel), w.is_pds, w.max_hail_size, - w.max_wind_gust_knots, quote(w.spc_url), quote(w.issued_at), - quote(w.expires_at), rings_text(w.rings)); + number, year, quote(type), quote(sel), is_pds, max_hail_size, + max_wind_gust_knots, quote(spc_url), quote(issued_at), quote(expires_at), + rings_text(rings)); } return out; } inline std::string to_text(const StormReportPayload& payload) { - std::string out = std::format("StormReportPayload reports={}\n", payload.reports.size()); - for (const StormReport& r : payload.reports) { - out += - std::format("location=({} {}) type={} type_text={} magnitude={} unit={} city={} " - "county={} state={} wfo={} source={} remark={} valid_at={}\n", - r.location.lon, r.location.lat, quote(r.type), quote(r.type_text), - r.magnitude, quote(r.unit), quote(r.city), quote(r.county), quote(r.state), - quote(r.wfo), quote(r.source), quote(r.remark), quote(r.valid_at)); + const auto& [reports] = payload; + std::string out = std::format("StormReportPayload reports={}\n", reports.size()); + for (const auto& [location, type, type_text, magnitude, unit, city, county, state, wfo, source, + remark, valid_at] : reports) { + const auto& [lon, lat] = location; + out += std::format("location=({} {}) type={} type_text={} magnitude={} unit={} city={} " + "county={} state={} wfo={} source={} remark={} valid_at={}\n", + lon, lat, quote(type), quote(type_text), magnitude, quote(unit), + quote(city), quote(county), quote(state), quote(wfo), quote(source), + quote(remark), quote(valid_at)); } return out; } diff --git a/tests/test_allocation_counter.cpp b/tests/test_allocation_counter.cpp index 2f441ea..575dea1 100644 --- a/tests/test_allocation_counter.cpp +++ b/tests/test_allocation_counter.cpp @@ -4,13 +4,13 @@ #include "support/allocation_counter.hpp" +#include #include #include #include #include #include #include -#include #include namespace { @@ -63,43 +63,66 @@ TEST(AllocationCounter, FreeingOlderMemoryMakesRetainedNegative) { EXPECT_LE(stats.retained_bytes, -1000); } -TEST(AllocationCounter, CountsEveryOperatorNewForm) { - constexpr std::align_val_t kAlignment{64}; +TEST(AllocationCounter, CountsEveryOperatorNewAndDeleteForm) { + constexpr std::size_t kSize = 8; + constexpr std::uintptr_t kAlignmentBytes = 64; + constexpr std::align_val_t kAlignment{kAlignmentBytes}; const AllocationProbe probe; - void* plain = ::operator new(8); - void* array = ::operator new[](8); - void* nothrow = ::operator new(8, std::nothrow); - void* aligned = ::operator new(8, kAlignment); - void* aligned_array = ::operator new[](8, kAlignment, std::nothrow); - for (const void* block : {plain, array, nothrow, aligned, aligned_array}) { - escaped = block; + // Twelve blocks from the eight new forms, one for each delete form. + const std::array single = {::operator new(kSize), ::operator new(kSize, std::nothrow), + ::operator new(kSize)}; + const std::array array = { + ::operator new[](kSize), ::operator new[](kSize, std::nothrow), ::operator new[](kSize)}; + const std::array aligned = {::operator new(kSize, kAlignment), + ::operator new(kSize, kAlignment, std::nothrow), + ::operator new(kSize, kAlignment)}; + const std::array aligned_array = {::operator new[](kSize, kAlignment), + ::operator new[](kSize, kAlignment, std::nothrow), + ::operator new[](kSize, kAlignment)}; + bool aligned_ok = true; + for (const std::array& blocks : {aligned, aligned_array}) { + for (const void* block : blocks) { + aligned_ok = aligned_ok && std::bit_cast(block) % kAlignmentBytes == 0; + } } - const bool aligned_ok = std::bit_cast(aligned) % 64 == 0 && - std::bit_cast(aligned_array) % 64 == 0; - ::operator delete(plain, 8); - ::operator delete[](array); - ::operator delete(nothrow, std::nothrow); - ::operator delete(aligned, 8, kAlignment); - ::operator delete[](aligned_array, kAlignment); + for (const std::array& blocks : {single, array, aligned, aligned_array}) { + for (const void* block : blocks) { + escaped = block; + } + } + ::operator delete(single[0]); + ::operator delete(single[1], kSize); + ::operator delete(single[2], std::nothrow); + ::operator delete[](array[0]); + ::operator delete[](array[1], kSize); + ::operator delete[](array[2], std::nothrow); + ::operator delete(aligned[0], kAlignment); + ::operator delete(aligned[1], kSize, kAlignment); + ::operator delete(aligned[2], kAlignment, std::nothrow); + ::operator delete[](aligned_array[0], kAlignment); + ::operator delete[](aligned_array[1], kSize, kAlignment); + ::operator delete[](aligned_array[2], kAlignment, std::nothrow); const AllocationStats stats = probe.stats(); - EXPECT_EQ(stats.allocations, 5u); - EXPECT_EQ(stats.bytes, 40u); + EXPECT_EQ(stats.allocations, 12u); + EXPECT_EQ(stats.bytes, 12 * kSize); + // Each delete form gave its block back to the count. EXPECT_EQ(stats.retained_bytes, 0); EXPECT_TRUE(aligned_ok); } TEST(AllocationCounter, SeesAllocationsMadeInsideTheStandardLibrary) { - // libc++ and libstdc++ compile std::string's growth into their shared - // library, so this checks that the replacement reaches it there too. + // std::runtime_error copies its message inside libc++ and libstdc++, so + // this checks that the replacement reaches allocations made there. + // std::string growth would not show that: GCC inlines it from C++20 on. + constexpr char kMessage[] = "a message longer than any short-string buffer"; const AllocationProbe probe; - std::string text; - text.append(5000, 'x'); - escaped = text.data(); + const std::runtime_error error{kMessage}; + escaped = error.what(); const AllocationStats stats = probe.stats(); EXPECT_GE(stats.allocations, 1u); - EXPECT_GE(stats.bytes, 5001u); + EXPECT_GE(stats.bytes, sizeof(kMessage)); } TEST(AllocationCounter, StartsEachProbeFromZero) { diff --git a/tests/test_golden.cpp b/tests/test_golden.cpp index 8e5b40f..4eca432 100644 --- a/tests/test_golden.cpp +++ b/tests/test_golden.cpp @@ -33,6 +33,7 @@ namespace { using namespace spc; +using test::quote; using test::read_fixture; using test::to_text; @@ -134,7 +135,7 @@ std::string_view excerpt(std::string_view line, std::size_t column) { } /// Where `actual` first departs from `expected`, as a line, a column, and -/// both versions of that line. +/// both versions of that line, escaped so a stray \r or tab shows. std::string first_difference(std::string_view expected, std::string_view actual) { std::size_t line = 1; std::size_t line_start = 0; @@ -149,8 +150,8 @@ std::string first_difference(std::string_view expected, std::string_view actual) const std::string_view want = expected.substr(line_start, expected.find('\n', i) - line_start); const std::string_view got = actual.substr(line_start, actual.find('\n', i) - line_start); return std::format("first difference at line {}, column {}\n expected: {}\n actual: {}", - line, i - line_start + 1, excerpt(want, i - line_start), - excerpt(got, i - line_start)); + line, i - line_start + 1, quote(excerpt(want, i - line_start)), + quote(excerpt(got, i - line_start))); } class Golden : public testing::TestWithParam {}; @@ -191,7 +192,8 @@ TEST(GoldenCoverage, EveryFixtureIsParsedOrListedAsNotParsed) { for (const std::filesystem::directory_entry& entry : std::filesystem::directory_iterator(SPC_FIXTURES_DIR)) { const std::string name = entry.path().filename().string(); - if (name == "README.md" || name == "SHA256SUMS") { + // Dotfiles such as a Finder .DS_Store are not fixtures. + if (name == "README.md" || name == "SHA256SUMS" || name.starts_with('.')) { continue; } EXPECT_TRUE(has_case(name) || std::ranges::find(kNotParsed, name) != kNotParsed.end())