blob: 9fc08b2d6f065e9d8292c4f2d84bd0db5138a11b [file] [log] [blame]
# Copyright 2023 The Bazel Authors. All rights reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Utilities for performing comparisons for Truth."""
load(":truth_common.bzl", "to_list")
def _match_result_new(*, found_at, matched_value, matcher):
"""Creates a "MatchResult" struct.
A `MatchResult` struct is information about how an expected value
matched to an actual value.
Args:
found_at: ([`int`]) the position in the actual container the match
occurred at.
matched_value: the actual value that caused the match
matcher: ([`Matcher`] | value) the value that matched
"""
return struct(
found_at = found_at,
matched_value = matched_value,
matcher = matcher,
)
# We use this name so it shows up nice in docs.
# buildifier: disable=name-conventions
MatchResult = struct(
new = _match_result_new,
)
def compare_contains_exactly_predicates(*, expect_contains, actual_container):
"""Tells how and if values and predicates correspond 1:1 in the specified order.
* There must be a 1:1 correspondence between the container values and the
predicates.
* Multiplicity is respected (i.e., if the same predicate occurs twice, then
two distinct elements must match).
* Matching occurs in first-seen order. That is, a predicate will "consume"
the first value in `actual_container` it matches.
* The collection must match all the predicates, no more or less.
* Checking that the order of matches is the same as the passed-in matchers
order can be done by call `in_order()`.
Note that confusing results may occur if predicates with overlapping
match conditions are used. For example, given:
actual=["a", "ab", "abc"],
predicates=[<contains a>, <contains b>, <equals a>]
Then the result will be they aren't equal: the first two predicates
consume "a" and "ab", leaving only "abc" for the <equals a> predicate
to match against, which fails.
Args:
expect_contains: ([`collection`] of `Matcher`s) the predicates that must match.
To perform simple equalty, use `matching.equals_wrapper()`.
actual_container: ([`collection`]) The container to check within.
Returns:
struct with the following attributes:
* contains_exactly: ([`bool`]) True if all the predicates (and no others)
matched a distinct element; does not consider order.
* is_in_order: ([`bool`]) True if the actuals values matched in the same
order as the expected predicates. False if they were out of order.
If `contains_exactly=False`, this attribute is undefined.
* missing: [`list`] of [`Matcher`]s from `expect_contains` that did not find a
corresponding element in `actual_container`.
* unexpected: ([`list`]) values from `actual_container` that were not
present in `expect_contains`.
* matches: ([`list`] of [`MatchResult`]) information about which elements
in the two lists that matched each other. If
`contains_exactly=False`, this attribute is undefined.
"""
# The basic idea is treating the expected and actual lists as queues of
# remaining values to search for. This allows the multiplicity of values
# to be respected and ordering correctness to be computed.
#
# Each iteration, we "pop" an element off each queue and...
# * If the elements are equal, then all is good: ordering is still
# possible, and the required element is present. Start a new iteration.
# * Otherwise, we know ordering isn't possible anymore and need to
# answer two questions:
# 1. Is the actual value extra, or elsewhere in the expected values?
# 2. Is the expected value missing, or elsewhere in the actual values?
# If a value exists elsewhere in the other queue, then we have to
# remove it to prevent it from being searched for again in a later
# iteration.
# As we go along, we keep track of where expected values matched; this
# allows for better error reporting.
expect_contains = to_list(expect_contains)
actual_container = to_list(actual_container)
actual_queue = [] # List of (original pos, value)
for i, value in enumerate(actual_container):
actual_queue.append([i, value])
expected_queue = [] # List of (original pos, value)
matches = [] # List of "MatchResult" structs
for i, value in enumerate(expect_contains):
expected_queue.append([i, value])
matches.append(None)
missing = [] # List of expected values missing
unexpected = [] # List of actual values that weren't expected
ordered = True
# Start at -1 because the first iteration adds 1
actual_pos = -1
expected_pos = -1
loop = range(max(len(actual_queue), len(expected_queue)))
for _ in loop:
# Advancing the position is equivalent to removing the queues's head
actual_pos += 1
expected_pos += 1
if actual_pos >= len(actual_queue) and expected_pos >= len(expected_queue):
# Can occur when e.g. actual=[A, B], expected=[B]
break
if actual_pos >= len(actual_queue):
# Fewer actual values than expected, so the rest are missing
missing.extend([v[1] for v in expected_queue[expected_pos:]])
break
if expected_pos >= len(expected_queue):
# More actual values than expected, so the rest are unexpected
unexpected.extend([v[1] for v in actual_queue[actual_pos:]])
break
actual_entry = actual_queue[actual_pos]
expected_entry = expected_queue[expected_pos]
if expected_entry[1].match(actual_entry[1]):
continue # Happy path: both are equal and order is maintained.
ordered = False
found_at, found_entry = _list_find(
actual_queue,
lambda v: expected_entry[1].match(v[1]),
start = actual_pos,
end = len(actual_queue),
)
if found_at == -1:
missing.append(expected_entry[1])
else:
# Remove it from the queue so a subsequent iteration doesn't
# try to search for it again.
actual_queue.pop(found_at)
matches[expected_entry[0]] = MatchResult.new(
found_at = found_entry[0],
matched_value = found_entry[1],
matcher = expected_entry[1],
)
found_at, found_entry = _list_find(
expected_queue,
lambda entry: entry[1].match(actual_entry[1]),
start = expected_pos,
end = len(expected_queue),
)
if found_at == -1:
unexpected.append(actual_entry[1])
else:
# Remove it from the queue so a subsequent iteration doesn't
# try to search for it again.
expected_queue.pop(found_at)
matches[found_entry[0]] = MatchResult.new(
found_at = actual_entry[0],
matched_value = actual_entry[1],
matcher = found_entry[1],
)
return struct(
contains_exactly = not (missing or unexpected),
is_in_order = ordered,
missing = missing,
unexpected = unexpected,
matches = matches,
)
def _list_find(search_in, search_for, *, start = 0, end = None):
"""Linear search a list for a value matching a predicate.
Args:
search_in: ([`list`]) the list to search within.
search_for: (callable) accepts 1 positional arg (the current value)
and returns `bool` (`True` if matched, `False` if not).
start: ([`int`]) the position within `search_in` to start at. Defaults
to `0` (start of list)
end: (optional [`int`]) the position within `search_in` to stop before
(i.e. the value is exclusive; given a list of length 5, specifying
`end=5` means it will search the whole list). Defaults to the length
of `search_in`.
Returns:
[`tuple`] of ([`int`] found_at, value).
* If the value was found, then `found_at` is the offset in `search_in`
it was found at, and matched value is the element at that offset.
* If the value was not found, then `found_at=-1`, and the matched
value is `None`.
"""
end = len(search_in) if end == None else end
pos = start
for _ in search_in:
if pos >= end:
return -1, None
value = search_in[pos]
if search_for(value):
return pos, value
pos += 1
return -1, None
def compare_dicts(*, expected, actual):
"""Compares two dicts, reporting differences.
Args:
expected: ([`dict`]) the desired state of `actual`
actual: ([`dict`]) the observed dict
Returns:
Struct with the following attributes:
* missing_keys: [`list`] of keys that were missing in `actual`, but present
in `expected`
* unexpected_keys: [`list`] of keys that were present in `actual`, but not
present in `expected`
* incorrect_entries: ([`dict`] of key -> [`DictEntryMismatch`]) of keys that
were in both dicts, but whose values were not equal. The value is
a "DictEntryMismatch" struct, which is defined as a struct with
attributes:
* `actual`: the value from `actual[key]`
* `expected`: the value from `expected[key]`
"""
all_keys = {key: None for key in actual.keys()}
all_keys.update({key: None for key in expected.keys()})
missing_keys = []
unexpected_keys = []
incorrect_entries = {}
for key in sorted(all_keys):
if key not in actual:
missing_keys.append(key)
elif key not in expected:
unexpected_keys.append(key)
else:
actual_value = actual[key]
expected_value = expected[key]
if actual_value != expected_value:
incorrect_entries[key] = struct(
actual = actual_value,
expected = expected_value,
)
return struct(
missing_keys = missing_keys,
unexpected_keys = unexpected_keys,
incorrect_entries = incorrect_entries,
)