Skip to content
184 changes: 184 additions & 0 deletions TUnit.Assertions.Tests/DictionaryCollectionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -626,4 +626,188 @@ public async Task IDictionary_ContainsKey_With_Custom_Comparer()
await Assert.That(dictionary)
.ContainsKey("HELLO", StringComparer.OrdinalIgnoreCase);
}

// ===================================
// ContainsKey(...).And.Value drill-in (#6185)
// ===================================

[Test]
public async Task Dictionary_ContainsKey_And_Value_IsEqualTo_Passes()
{
var dictionary = new Dictionary<string, long>
{
["Key"] = 1234L
};

await Assert.That(dictionary).ContainsKey("Key").And.Value.IsEqualTo(1234L);
}

[Test]
public async Task Dictionary_ContainsKey_And_Value_IsEqualTo_Fails_When_Value_Different()
{
var dictionary = new Dictionary<string, long>
{
["Key"] = 1234L
};

var exception = await Assert.ThrowsAsync<TUnit.Assertions.Exceptions.AssertionException>(
async () => await Assert.That(dictionary).ContainsKey("Key").And.Value.IsEqualTo(9999L));

await Assert.That(exception.Message).Contains("1234");
}

[Test]
public async Task Dictionary_ContainsKey_And_Value_Fails_When_Key_Missing()
{
var dictionary = new Dictionary<string, long>
{
["Key"] = 1234L
};

var exception = await Assert.ThrowsAsync<TUnit.Assertions.Exceptions.AssertionException>(
async () => await Assert.That(dictionary).ContainsKey("Missing").And.Value.IsEqualTo(1234L));

// The ContainsKey check runs first (pre-work), so a missing key fails with the
// standard "contain key" message rather than a raw KeyNotFoundException.
await Assert.That(exception.Message).Contains("contain key");
}

[Test]
public async Task Dictionary_ContainsKey_And_Value_Member()
{
var dictionary = new Dictionary<string, Holder>
{
["Key"] = new Holder(1234L)
};

await Assert.That(dictionary)
.ContainsKey("Key").And.Value.Member(x => x.Inner, p => p.IsEqualTo(1234L));
}

[Test]
public async Task Dictionary_ContainsKey_And_Value_Supports_Other_Assertions()
{
var dictionary = new Dictionary<string, long>
{
["Key"] = 1234L
};

await Assert.That(dictionary).ContainsKey("Key").And.Value.IsGreaterThan(1000L);
await Assert.That(dictionary).ContainsKey("Key").And.Value.IsNotEqualTo(0L);
}

[Test]
public async Task Dictionary_ContainsKey_And_Value_Then_Value_Level_And()
{
var dictionary = new Dictionary<string, long>
{
["Key"] = 1234L
};

await Assert.That(dictionary)
.ContainsKey("Key").And.Value.IsGreaterThan(1000L).And.IsLessThan(2000L);
}

[Test]
public async Task Dictionary_And_Value_Still_Allows_Dictionary_Chaining()
{
var dictionary = new Dictionary<string, long>
{
["Key"] = 1234L,
["Other"] = 1L
};

// The And continuation still exposes the regular dictionary methods.
await Assert.That(dictionary).ContainsKey("Key").And.ContainsKey("Other");
}

[Test]
public async Task Dictionary_LongerChain_Then_Value()
{
var dictionary = new Dictionary<string, long>
{
["First"] = 1L,
["Key"] = 1234L
};

// Earlier assertions in the chain run as pre-work before the value is read.
await Assert.That(dictionary)
.ContainsKey("First").And.ContainsKey("Key").And.Value.IsEqualTo(1234L);
}

[Test]
public async Task Dictionary_ContainsKey_With_Comparer_And_Value()
{
var dictionary = new Dictionary<string, long>
{
["Hello"] = 1234L
};

await Assert.That(dictionary)
.ContainsKey("HELLO", StringComparer.OrdinalIgnoreCase).And.Value.IsEqualTo(1234L);
}

[Test]
public async Task IReadOnlyDictionary_ContainsKey_And_Value_IsEqualTo()
{
IReadOnlyDictionary<string, long> dictionary = new Dictionary<string, long>
{
["Key"] = 1234L
};

await Assert.That(dictionary).ContainsKey("Key").And.Value.IsEqualTo(1234L);
}

[Test]
public async Task IDictionary_ContainsKey_And_Value_IsEqualTo()
{
IDictionary<string, long> dictionary = new Dictionary<string, long>
{
["Key"] = 1234L
};

await Assert.That(dictionary).ContainsKey("Key").And.Value.IsEqualTo(1234L);
}

[Test]
public async Task Dictionary_IsNotEmpty_Preserves_Dictionary_Continuation()
{
var dictionary = new Dictionary<string, long>
{
["Key"] = 1234L
};

// IsNotEmpty now keeps the dictionary continuation, so ContainsKey/.Value remain available.
await Assert.That(dictionary)
.IsNotEmpty()
.And.ContainsKey("Key").And.Value.IsEqualTo(1234L);
}

[Test]
public async Task Dictionary_IsEmpty_Preserves_Dictionary_Continuation()
{
var nonEmpty = new Dictionary<string, long>
{
["Key"] = 1234L
};

await Assert.That(nonEmpty)
.IsEmpty()
.Or.ContainsKey("Key");
}

[Test]
public async Task IDictionary_IsNotEmpty_Preserves_Dictionary_Continuation()
{
IDictionary<string, long> dictionary = new Dictionary<string, long>
{
["Key"] = 1234L
};

await Assert.That(dictionary)
.IsNotEmpty()
.And.ContainsKey("Key").And.Value.IsEqualTo(1234L);
}

private sealed record Holder(long Inner);
}
78 changes: 78 additions & 0 deletions TUnit.Assertions/Conditions/DictionaryAssertions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,22 @@ public DictionaryContainsKeyAssertion<TDictionary, TKey, TValue> Using(Func<TKey
Context, _expectedKey, new FuncEqualityComparer<TKey>(equalityPredicate));
}

/// <summary>
/// Returns an And continuation that, in addition to the usual dictionary chaining, exposes
/// <see cref="DictionaryContainsKeyAndContinuation{TDictionary,TKey,TValue}.Value"/> to drill
/// into the value stored at the asserted key.
/// Example: <c>await Assert.That(dict).ContainsKey("key").And.Value.IsEqualTo(123);</c>
/// </summary>
public new DictionaryContainsKeyAndContinuation<TDictionary, TKey, TValue> And
{
get
{
ThrowIfMixingCombiner<Chaining.OrAssertion<TDictionary>>();
return new DictionaryContainsKeyAndContinuation<TDictionary, TKey, TValue>(
Context, InternalWrappedExecution ?? this, _expectedKey, _comparer);
}
}

protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<TDictionary> metadata)
{
var value = metadata.Value;
Expand Down Expand Up @@ -391,3 +407,65 @@ protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<TDictiona

protected override string GetExpectation() => "any value to satisfy the predicate";
}

/// <summary>
/// Asserts that a dictionary is empty while preserving dictionary-specific chaining.
/// </summary>
public class DictionaryIsEmptyAssertion<TDictionary, TKey, TValue> : Sources.DictionaryAssertionBase<TDictionary, TKey, TValue>
where TDictionary : IReadOnlyDictionary<TKey, TValue>
where TKey : notnull
{
public DictionaryIsEmptyAssertion(AssertionContext<TDictionary> context)
: base(context)
{
}

protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<TDictionary> metadata)
{
if (metadata.Exception != null)
{
return Task.FromResult(AssertionResult.Failed($"threw {metadata.Exception.GetType().Name}", metadata.Exception));
}

if (metadata.Value == null)
{
return Task.FromResult(AssertionResult.Failed("dictionary was null"));
}

var adapter = new EnumerableAdapter<KeyValuePair<TKey, TValue>>(metadata.Value);
return Task.FromResult(CollectionChecks.CheckIsEmpty(adapter));
}

protected override string GetExpectation() => "to be empty";
}

/// <summary>
/// Asserts that a dictionary is not empty while preserving dictionary-specific chaining.
/// </summary>
public class DictionaryIsNotEmptyAssertion<TDictionary, TKey, TValue> : Sources.DictionaryAssertionBase<TDictionary, TKey, TValue>
where TDictionary : IReadOnlyDictionary<TKey, TValue>
where TKey : notnull
{
public DictionaryIsNotEmptyAssertion(AssertionContext<TDictionary> context)
: base(context)
{
}

protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<TDictionary> metadata)
{
if (metadata.Exception != null)
{
return Task.FromResult(AssertionResult.Failed($"threw {metadata.Exception.GetType().Name}", metadata.Exception));
}

if (metadata.Value == null)
{
return Task.FromResult(AssertionResult.Failed("dictionary was null"));
}

var adapter = new EnumerableAdapter<KeyValuePair<TKey, TValue>>(metadata.Value);
return Task.FromResult(CollectionChecks.CheckIsNotEmpty(adapter));
}

protected override string GetExpectation() => "to not be empty";
}
Loading
Loading