Skip to content

feat(testing): add IsElementOf / IsKeyOf / IsValueOf matchers#181

Merged
helly25 merged 11 commits into
mainfrom
feat/20260516_is_element_of
May 17, 2026
Merged

feat(testing): add IsElementOf / IsKeyOf / IsValueOf matchers#181
helly25 merged 11 commits into
mainfrom
feat/20260516_is_element_of

Conversation

@helly25
Copy link
Copy Markdown
Owner

@helly25 helly25 commented May 16, 2026

Summary

gmock's Contains(value) puts the container on the left and the element on the right; the opposite orientation — is this value a member of that container? — has no built-in matcher. AnyOfArray is the closest fit but parameterizes its inner matchers on the container's value_type, so EXPECT_THAT("a", AnyOfArray(vector<string>{...})) fails to compile because the subject must be the same (or strictly implicitly-convertible) type.

This adds three public matchers in mbo/testing/matchers.h, all using the outer/Impl pattern already established by CapacityIsMatcher / WhenTransformedByMatcher:

  • IsElementOf(container) — value-on-the-left matcher. Comparison goes through a testing_internal::ElementEqual helper that uses raw operator== when the two sides are directly equality-comparable, and falls back to component-wise comparison for std::pair. This lets const char* / std::string_view / std::string subjects all match against a std::vector<std::string> (or std::map<std::string, ...> keys, etc.) without caller-side conversion, and lets std::pair<const char*, const char*> match against std::list<std::pair<std::string, std::string>> even on libstdc++ (C++20 narrowed std::pair::operator== to same-types-only; libc++ accepts heterogeneous via implicit pair conversion, libstdc++ does not).

  • IsKeyOf(map) — convenience matcher: "is this value among the keys of map?". Failure messages say "is a key of {1, 2}" / "is not a key of {1, 2}".

  • IsValueOf(map) — parallel to IsKeyOf: "is this value among the mapped values of map?". Failure messages say "is a value of {\"a\", \"b\"}" / "is not a value of {\"a\", \"b\"}".

AllKeys / AllValues (the eager std::vector<key_type> / std::vector<mapped_type> projections) are kept in the testing_internal namespace, not exposed publicly. The shapes they enable — IsElementOf(AllKeys(m)) and IsElementOf(AllValues(m)) — are exactly IsKeyOf / IsValueOf, and keeping the projections internal prevents them from being misused as left-side subjects (EXPECT_THAT(AllKeys(m), ...)), which would invert the orientation the matcher exists to provide.

Description leads

IsElementOfMatcher carries configurable positive / negative description leads so failure messages reflect which projection was applied:

  • IsElementOf(c)"is element of {…}" / "is not element of {…}"
  • IsKeyOf(m)"is a key of {…}" / "is not a key of {…}"
  • IsValueOf(m)"is a value of {…}" / "is not a value of {…}"

.trunk/trunk.yaml adjustments

  • Drops clang-format@19.1.6: pre-commit's mirrors-clang-format already pins 19.1.6 and works on every platform, while trunk.io's CDN does not host the macOS-arm64 binary for that version. Removing it from trunk eliminates the install failure and the version-split risk; CI's existing pre-commit job continues to enforce formatting.
  • Drops clang-tidy@19.1.7 → clang-tidy@16.0.3: trunk.io's CDN also doesn't host clang-tidy-19.1.7-linux-x86_64.tar.gz. 16.0.3 is the plugin's known_good_version and is available on every platform. Pre-commit has no clang-tidy hook, so trunk remains the sole runner; only the version moves.

Test plan

  • bazel test //mbo/testing:matchers_test — new tests pass locally (macOS-arm64, libc++)
  • Heterogeneous subject types verified: const char[], std::string_view, std::string against std::vector<std::string> and std::map<std::string, ...>
  • Pair-of-string-literals subject against std::list<std::pair<std::string, std::string>> — exercises the ElementEqual pair fallback (relevant on libstdc++)
  • Edge cases: empty containers, multimap (duplicate keys preserved), unordered_map (hash-order-independent)
  • Exact Describe / DescribeNegation / MatchAndExplain strings asserted for IsElementOf, IsKeyOf, and IsValueOf (including empty-map edge cases)
  • CI green across pre-commit, trunk, and test-gcc (linux + libstdc++)

🤖 Generated with Claude Code

helly25 and others added 9 commits May 16, 2026 21:25
gmock's `Contains(value)` puts the container on the left and the
element on the right. The opposite orientation — "this value must be
a member of this container" — has no built-in matcher. `AnyOfArray`
is close but parameterizes its inner matchers on the container's
`value_type`, so the subject must be the same (or strictly
implicitly-convertible) type; a `const char*` subject against a
`std::vector<std::string>` fails to compile.

This adds four helpers in `mbo/testing/matchers.h`, each a custom
polymorphic matcher using the outer/Impl pattern already established
by `CapacityIsMatcher` and `WhenTransformedByMatcher`:

  * `IsElementOf(container)` — value-on-the-left matcher. Iterates
    the container and compares via raw `operator==`, so any
    equality-comparable subject works without explicit conversion
    (string literal vs `vector<string>`, `string_view` vs
    `map<string, ...>` keys, etc.). Container is taken by value to
    keep temporaries and initializer lists safe.
  * `AllKeys(map)` / `AllValues(map)` — eager projections returning
    `std::vector<key_type>` / `std::vector<mapped_type>` for
    composition with element-oriented matchers.
  * `IsKeyOf(map)` — short-hand for `IsElementOf(AllKeys(map))`.

Tests cover happy path, rvalue/initializer-list containers, empty
containers, heterogeneous element types (string literal, string_view,
string), `std::map`, `std::unordered_map` (hash-order-independent),
`std::multimap` (duplicate keys preserved), and exact
Describe/DescribeNegation/MatchAndExplain strings for matching and
non-matching subjects.

Also drops `clang-format@19.1.6` from `.trunk/trunk.yaml`: pre-commit
already runs clang-format at 19.1.6 via mirrors-clang-format and
covers both Linux and macOS-arm64, while trunk.io's CDN does not host
the macOS-arm64 binary for that version. With pre-commit owning
clang-format, there's no version split and no install failure. CI's
existing pre-commit job still enforces formatting.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Several IsElementOf / IsKeyOf assertions wrapped the left-hand value
in std::string{...} even though IsElementOf is built specifically so
the subject does not need conversion to the container's value_type.
The conversions silently masked the case the matcher was designed
to solve — left-hand string literals should compile and match
against a vector<string> / map<string, ...> directly.

Lines 352, 366, 367 retain std::string{...} on purpose: those tests
exercise all three subject shapes (const char[], string_view, string)
to prove each one works.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two new tests pin down the no-conversion-on-the-left guarantee in
two more shapes:

  * IsElementOfVectorOfStrings: const char[] subject vs
    std::vector<std::string> elements.
  * IsElementOfListOfStringPairs: std::pair<const char*, const char*>
    subject vs std::list<std::pair<std::string, std::string>>
    elements, relying on pair::operator== to compare componentwise
    using string == const char*.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous AllKeysAndValuesFromMap / AllKeysAndValuesFromEmptyMap /
AllKeysFromUnorderedMap tests put `AllKeys(m)` and `AllValues(m)` on
the left as the subject under test (paired with `ElementsAre` /
`IsEmpty` / `UnorderedElementsAre`). That's the very orientation
`IsElementOf` exists to avoid.

Rewrite each as `EXPECT_THAT(element, IsElementOf(AllKeys(m)))` so
the projections appear inside the matcher on the right — the form
callers will actually use. Each test now checks both a present and
an absent element so the assertion is non-trivial; the empty-map
case checks several distinct subjects to make the "nothing is in
empty" property concrete.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The earlier round bounced between IsElementOf and ElementsAre /
UnorderedElementsAre — different matchers. ElementsAre tests the
projection function's exact return value with the container on the
left; IsElementOf is what we are actually testing in this PR and puts
the element on the left.

Replace all AllKeys/AllValues assertions with the IsElementOf form
across std::map, std::unordered_map and std::multimap. Each case
enumerates every present key (and a representative absent key) for
both AllKeys and AllValues.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ions

Three connected changes:

  * Add `IsValueOf(map)` matcher parallel to `IsKeyOf(map)`. With
    both, callers never need to write `IsElementOf(AllValues(map))`
    or `IsElementOf(AllKeys(map))` directly.

  * Move `AllKeys` / `AllValues` from `mbo::testing` to the
    `mbo::testing::testing_internal` namespace. Their only sensible
    use is inside a matcher on the right side of `EXPECT_THAT`; the
    public API never wants them as a subject on the left.

  * Give `IsElementOfMatcher` configurable description-lead strings.
    `IsKeyOf` describes as "is a key of {1, 2}" / "is not a key of
    {1, 2}", `IsValueOf` as "is a value of {\"a\", \"b\"}" /
    "is not a value of {\"a\", \"b\"}", `IsElementOf` keeps the
    existing "is element of {…}" wording. Failure messages now
    reflect which projection was applied.

Tests updated to use the new matchers and to pin the exact new
description strings for IsKeyOf / IsValueOf (and their empty-map
edge cases).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two CI fixes:

  * Trunk plugin v1.10.0 does not host clang-tidy 19.1.7 on
    trunk.io's CDN (HTTP 403 on linux-x86_64 — same kind of gap we
    already hit for clang-format on macos-arm64). Drop the version
    to 16.0.3, which is the plugin's known_good_version and is
    hosted on every platform. Pre-commit has no clang-tidy hook, so
    trunk remains the sole runner; only the version moves.
  * Apply the two clang-format fixes pre-commit was asking for: a
    line-collapse in IsValueOf and a comment-alignment in the
    heterogeneous IsElementOf test.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
GCC 13 + libstdc++ rejects
  std::pair<const char*, const char*> == std::pair<std::string, std::string>
at the compile level, because C++20 narrowed std::pair::operator== to same
types only. libc++ on macOS quietly allows it via implicit pair conversion;
libstdc++ does not. The matcher's "no caller-side conversion needed" promise
broke for the pair-of-string-literals case on Linux as a result.

Add a small ElementEqual helper that uses raw operator== when the two sides
are directly equality-comparable, and falls back to a component-wise pair
comparison otherwise. The recursive pair branch compares `first` and
`second` independently using the same helper, so any combination of
heterogeneous element types that are individually comparable (e.g.
const char* against std::string) keeps working.

Wire IsElementOf's MatchAndExplain through ElementEqual instead of raw ==.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Blank line after the empty IsStdPair primary template and collapse the
`else if constexpr` onto a single line, per clang-format 19.1.6.
@helly25 helly25 changed the title feat(testing): add IsElementOf / IsKeyOf / AllKeys / AllValues matchers feat(testing): add IsElementOf / IsKeyOf / IsValueOf matchers May 16, 2026
helly25 and others added 2 commits May 16, 2026 22:32
The new IsElementOf / IsKeyOf / IsValueOf matchers warrant a minor
version bump. Update the three places that need to move together:

  * MODULE.bazel: 0.10.0 -> 0.11.0
  * CHANGELOG.md: add a 0.11.0 section listing the new matchers
  * README.md: list the new matchers in the testing/matchers section

The `compare-versions` pre-commit hook checks that MODULE.bazel and
CHANGELOG.md stay in sync.

test-bcr's `module_version: [0.10.0]` matrix entry is intentionally
left alone — it tests against the latest Bazel Central Registry
release, which is still 0.10.0 until this PR ships.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CHANGELOG.md uses one top-level `#` heading per version, and the
`compare-versions` pre-commit hook + `tools/trigger_release.sh` both
depend on that exact regex (`^# ([0-9]...)$`) to extract the current
version. markdownlint's MD025 (single-h1) would force every version
heading to `##`, which would break the version-detection scripts in
two places.

Disable `single-h1` in .trunk/configs/.markdownlint.yaml. The
alternative — bumping every heading to `##` plus updating both
scripts — is a much larger and riskier change for a stylistic rule.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@helly25 helly25 enabled auto-merge (squash) May 16, 2026 23:08
@helly25 helly25 requested a review from Fab-Cat May 16, 2026 23:08
@helly25 helly25 merged commit 5ec0538 into main May 17, 2026
20 checks passed
@helly25 helly25 deleted the feat/20260516_is_element_of branch May 17, 2026 08:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants