Open gnprice opened 1 month ago
I think the core of what's happening is that when comparing maps, deepEquals
uses unorderedCompare
to do the work, comparing the two Map.entries
iterables.
In general it'd fundamentally be complex for unorderedCompare
to do better than this, because if some expected element doesn't match any actual element, it's not clear which actual element in particular it was intended to match. Doing better would require a fancier diff algorithm, like some sort of minimal-edit-distance computation rather than comparing elements only for equality.
But a map has extra structure which should enable deepEquals
to drill down to the specific mismatch in much the same way it already does for iterables. If some expected entry doesn't match any actual entry, then either:
It does get complicated (as I realize now, thinking through this further) if the expected key is a Condition
or Iterable
or Map
, rather than a value meant to be compared for equality.
I expect it's pretty uncommon to want a Condition
callback for the key of an expected map, though, and similarly to have an Iterable
or Map
for a map key. After all, the normal semantics of Map
are all about ==
comparisons on the keys. So it should be fine if the nicer error output only appears when the keys are all values meant to be compared for equality, with a fallback to the current behavior if some keys are Condition
callbacks or Iterable
or Map
.
It does get complicated (as I realize now, thinking through this further) if the expected key is a
Condition
orIterable
orMap
, rather than a value meant to be compared for equality.
Yes, it's possible to write a check where multiple keys can match. I agree that this is worth specializing for the sake of more specific error messages.
@gnprice - Can you try out the map-paths
branch and let me know how it works for you?
I'm a little nervous about how the behavior of some value key could change when you add a different non-value key into the expectation. I looked a little bit at unifying the behavior and choosing key by key which way to test it, but I haven't found a satisfying approach yet.
I just tried it out, and it works great for the use cases I had. Thanks for the swift implementation!
I'm a little nervous about how the behavior of some value key could change when you add a different non-value key into the expectation.
Yeah, that's reasonable. If I were maintaining this library, I think the key fact I'd try to convince myself of for reassurance is that if you add a non-value key into the expectation, and it matches either zero or one of the actual keys, then the check will pass (or fail) just if it would have passed (or failed) with a value key that matched the same zero or one of the actual keys.
As long as that's true, then it seems like this really is just a failure-message improvement, as intended, and can't cause subtle breakage. After all, if a non-value key matches multiple actual keys, then the test behavior may be counterintuitive but that's fundamental to what the test is choosing to do.
And though I haven't studied this closely enough to feel certain of it, I think that fact is true… as long as neither the actual nor expected maps do anything that breaks the Map contract, anyway, like having containsKey
and []
and entries
behave inconsistently from each other.
Which seems fine. If someone's working with collections that subvert the collection contracts, there's ultimately nothing that a general-purpose test-assertions framework can do to clear that up. It's really up to the user in that case to figure out what assumptions they can still rely on to make sense of those collections, and then to write the assertions they need given that.
There's one kind of collection I know of in the SDK that subverts the collection contracts to some degree: SplayTreeMap
. Because it uses compareTo
instead of ==
, it can give surprising results on types where those don't agree, like num
.
So here's a fun pair of demos:
test('odd map: match, compared as maps', () {
final m = SplayTreeMap<num, String>()
..[0.0] = 'zero'
..[-0.0] = 'zero'
..[1] = 'one';
check(m).deepEquals({0.0: 'zero', 1.0: 'one'});
});
test('odd map: mismatch, compared as sets of entries', () {
final m = SplayTreeMap<num, String>()
..[0.0] = 'zero'
..[-0.0] = 'zero'
..[1] = 'one';
check(m).deepEquals({0.0: 'zero', // FAIL
((Subject<Object?> it) => it.equals(1.0)): 'one'});
});
With your PR branch the first test passes, and the second fails. Both of them compare the same actual Map (a SplayTreeMap) against nearly the same Map, except that in the second test one of the keys is a condition (it) => it.equals(1.0)
instead of just the value 1.0
; this matters only in that it causes the "ambiguous" comparison to be used instead of the "unambiguous", and the former finds a mismatch while the latter finds a match.
I think this behavior is fine, though, just as I said above. The root of the confusion here is that there's a Map where keys
has two values that are equal according to ==
. Both "it matches" and "it doesn't match" are equally valid answers when compared against those expected maps, because the actual map is inconsistent when queried through different parts of the Map interface.
The
deepEquals
method is extremely handy for comparing some actual data to an expected list (or iterable), because when some item doesn't match it will drill in to pinpoint specifically why it didn't match, recursively.Currently it's a lot less helpful when the expected data is a map, though. If some detail deep within a big complicated value didn't match, it'll only print the whole expected value at that key, and say that something somewhere in there didn't match. (In fact it won't even point out the actual value at that key; so if the actual map as a whole is big, the user has to read through that to pick out the relevant key and its value.)
A real-world example is here: https://github.com/zulip/zulip-flutter/pull/762#discussion_r1678510731
For an isolated demo of the issue: take some blob of JSON, corrupt it, and use
deepEquals
to compare the decodings of the corrupted and original versions. Taking one such blob I had handy:the error output is long (full version below), and the most specific part is:
Ideally the end of the output would look more like:
Complete demo output
``` 00:01 +0 -1: demo [E] Expected: a MapWelcome to the Zulip development and user community!
Join to get a quick Zulip demo, observe a Zulip community, offer feedback to the Zulip core team, or get involved as a contributor.
Note that this server runs a bleeding-edge version of Zulip, so you may encounter bugs. Please report them!
', 'realm_web_public_access_enabled': true, 'external_authentication_methods': [{'name': 'google', 'display_name': 'Google', 'display_icon': 'https://chat.zulip.org/static/images/authentication_backends/googl_e-icon.png', 'login_url': '/accounts/login/social/google', 'signup_url': '/accounts/register/social/google'}, {'name': 'github', 'display_name': 'GitHub', 'display_icon': 'https://chat.zulip.org/static/images/authentication_backends/github-icon.png', 'login_url': '/accounts/login/social/github', 'signup_url': '/accounts/register/social/github'}, {'name': 'gitlab', 'display_name': 'GitLab', 'display_icon': 'https://chat.zulip.org/static/images/authentication_backends/gitlab-icon.png', 'login_url': '/accounts/login/social/gitlab', 'signup_url': '/accounts/register/social/gitlab'}, {'name': 'apple', 'display_name': 'Apple', 'display_icon': 'https://chat.zulip.org/static/images/authentication_backends/apple-icon.png', 'login_url': '/accounts/login/social/apple', 'signup_url': '/accounts/register/social/apple'}], 'realm_uri': 'https://chat.zulip.org'} Actual: {'result': 'success', 'msg': '', 'authentication_methods': {'password': true, 'dev': false, 'email': true, 'ldap': false, 'remoteuser': false, 'github': true, 'azuread': false, 'gitlab': true, 'google': true, 'apple': true, 'saml': false, 'openid connect': false}, 'zulip_version': '9.0-dev-3981-ga5f034fe46', 'zulip_merge_base': '9.0-dev-3975-gbdd39f4c67', 'zulip_feature_level': 273, 'push_notifications_enabled': true, 'is_incompatible': false, 'email_auth_enabled': true, 'require_email_format_usernames': true, 'realm_url': 'https://chat.zulip.org', 'realm_name': 'Zulip Community', 'realm_icon': '/user_avatars/2/realm/icon.png?version=3', 'realm_description': 'Welcome to the Zulip development and user community!
Join to get a quick Zulip demo, observe a Zulip community, offer feedback to the Zulip core team, or get involved as a contributor.
Note that this server runs a bleeding-edge version of Zulip, so you may encounter bugs. Please report them!
', 'realm_web_public_access_enabled': true, 'external_authentication_methods': [{'name': 'google', 'display_name': 'Google', 'display_icon': 'https://chat.zulip.org/static/images/authentication_backends/googl_e-icon.png', 'login_url': '/accounts/login/social/google', 'signup_url': '/accounts/register/social/google'}, {'name': 'github', 'display_name': 'Github', 'display_icon': 'https://chat.zulip.org/static/images/authentication_backends/github-icon.png', 'login_url': '/accounts/login/social/github', 'signup_url': '/accounts/register/social/github'}, {'name': 'gitlab', 'display_name': 'GitLab', 'display_icon': 'https://chat.zulip.org/static/images/authentication_backends/gitlab-icon.png', 'login_url': '/accounts/login/social/gitlab', 'signup_url': '/accounts/register/social/gitlab'}, {'name': 'apple', 'display_name': 'Apple', 'display_icon': 'https://chat.zulip.org/static/images/authentication_backends/apple-icon.png', 'login_url': '/accounts/login/social/apple', 'signup_url': '/accounts/register/social/apple'}], 'realm_uri': 'https://chat.zulip.org'} Which: has no entry to match 'external_authentication_methods': [{'name': 'google', 'display_name': 'Google', 'display_icon': 'https://chat.zulip.org/static/images/authentication_backends/googl_e-icon.png', 'login_url': '/accounts/login/social/google', 'signup_url': '/accounts/register/social/google'}, {'name': 'github', 'display_name': 'GitHub', 'display_icon': 'https://chat.zulip.org/static/images/authentication_backends/github-icon.png', 'login_url': '/accounts/login/social/github', 'signup_url': '/accounts/register/social/github'}, {'name': 'gitlab', 'display_name': 'GitLab', 'display_icon': 'https://chat.zulip.org/static/images/authentication_backends/gitlab-icon.png', 'login_url': '/accounts/login/social/gitlab', 'signup_url': '/accounts/register/social/gitlab'}, {'name': 'apple', 'display_name': 'Apple', 'display_icon': 'https://chat.zulip.org/static/images/authentication_backends/apple-icon.png', 'login_url': '/accounts/login/social/apple', 'signup_url': '/accounts/register/social/apple'}] package:checks/src/checks.dart 85:9 check.