using System.Reflection; using ErsatzTV.Core.Interfaces.Search; using ErsatzTV.Infrastructure.Search; using NUnit.Framework; using NUnit.Framework.Interfaces; using Shouldly; namespace ErsatzTV.Tests.Integration; /// /// ersatztv#824. The defect that produced #824 was not that ElasticSearchIndex was hard to /// test — it was that NOTHING NOTICED it had no cover. #701 fixed two independent copies of the same /// UpdateSong logic and pinned one; the suite stayed green, and the gap survived on a /// hand-written list of what had been covered (namely, one entry). /// /// So the covered set is compared against a population DERIVED FROM THE ASSEMBLY rather than /// restated: a third implementation reddens this test until it is /// given a mutation fixture of its own. That is /// testing.guard-derives-population-from-source applied to a test population instead of a /// file population. /// /// /// Scope, stated rather than implied. The derivation is over the assembly that declares /// both indexers (ErsatzTV.Infrastructure). An implementation added in a DIFFERENT /// assembly is outside what this sees — it is not covered and this test cannot say so. Both /// implementations have lived here since the interface existed, so the narrower scope buys a /// guard that cannot be defeated by an unrelated assembly load order; widening it to every /// loaded assembly would be the false-confidence version of the same check. /// /// [TestFixture] public class SearchIndexMutationCoverageTests { /// /// The indexers whose UpdateSong is pinned against the ersatztv#701 mutation. Referenced by /// TYPE, so renaming an indexer or deleting a fixture is a compile error rather than a silent /// divergence. /// private static readonly Dictionary CoveredBy = new() { [typeof(LuceneSearchIndex)] = typeof(SongIndexerMetadataMutationTests), [typeof(ElasticSearchIndex)] = typeof(ElasticSongIndexerMetadataMutationTests) }; [Test] public void Every_ISearchIndex_Implementation_Has_A_Metadata_Mutation_Fixture() { List implementations = typeof(LuceneSearchIndex).Assembly .GetTypes() .Where(t => t is { IsAbstract: false, IsInterface: false }) .Where(t => typeof(ISearchIndex).IsAssignableFrom(t)) .OrderBy(t => t.FullName, StringComparer.Ordinal) .ToList(); // The set comparison below would ALREADY fail on an empty derivation, because the expected side // is non-empty -- so this floor is not load-bearing for correctness and saying it is would be a // false claim about a check. It is a DIAGNOSTIC: it separates "the reflection stopped finding // types" (a moved type, a renamed interface) from "someone added an indexer", which the set // comparison alone reports identically. implementations.Count.ShouldBeGreaterThanOrEqualTo( 2, "the ISearchIndex population derivation found almost nothing -- this is the derivation " + "breaking, not an indexer being added"); implementations.ShouldBe( CoveredBy.Keys.OrderBy(t => t.FullName, StringComparer.Ordinal), ignoreOrder: false, "every ISearchIndex implementation needs an UpdateSong metadata-mutation fixture — see " + "ersatztv#824, where a second copy of the same logic went uncovered and an Elastic-only " + "reintroduction of `metadata.Artists ??= []` left the whole suite green"); // Comparing the KEYS alone would leave the mapping half-checked: a third indexer could be pointed // at an EXISTING fixture, or at a fixture class holding no runnable test, and the set comparison // above would still pass. Both are closed here. What NO static check can establish is that the // named fixture actually DRIVES its indexer -- that is stated as a residual on this guard rather // than implied away, and it is why the record claims a third implementation cannot be added // WITHOUT NOTICE, not that it cannot be mis-covered. CoveredBy.Values.Distinct().Count().ShouldBe( CoveredBy.Count, "two indexers are mapped to the SAME fixture, so one of them is not actually covered"); foreach ((Type indexer, Type fixture) in CoveredBy) { // Test-ness is decided by NUnit's INTERFACES, not by a hand-listed set of attribute types. // A list has to be kept in step with NUnit and silently falsely-reddens whatever it omits -- // an earlier revision listed TestAttribute alone (so a [TestCase]-only fixture failed), then // three types (so a [Theory] one did). ITestBuilder/ISimpleTestBuilder is what NUnit itself // dispatches on, so it cannot fall behind the vocabulary. // // DeclaredOnly is load-bearing: without it a fixture that merely SUBCLASSES another inherits // its [Test] and satisfies this while driving the wrong indexer -- and Values.Distinct() // cannot catch that, since the two Types differ. Static is included because NUnit runs a // public static test method in a non-static fixture, and omitting the flag would falsely // redden one. const BindingFlags TestMethods = BindingFlags.Public | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly; static bool NeverRuns(object[] attributes) => attributes.Any(a => a is ExplicitAttribute or IgnoreAttribute); bool declaresARunnableTest = fixture .GetMethods(TestMethods) .Select(m => m.GetCustomAttributes(inherit: true)) .Any(attrs => attrs.Any(a => a is ITestBuilder or ISimpleTestBuilder) && !NeverRuns(attrs)); declaresARunnableTest.ShouldBeTrue( $"{fixture.Name} is named as the mutation fixture for {indexer.Name} but DECLARES no " + "runnable test method of its own, so it proves nothing"); // The same "wired is not running" failure at fixture level, in its three forms: an abstract // class NUnit will not instantiate, and [Explicit]/[Ignore]. The indexer population at the top // of this method already filters IsAbstract; the fixture side needs the mirror of that. // `IsAbstract` ALONE is wrong here and would have been a false red: a C# `static class` // compiles to `abstract sealed`, and NUnit runs a test declared in one. What must be rejected // is an abstract BASE (abstract and NOT sealed), which NUnit cannot instantiate. Deliberately // no in-repo witness is cited: `AlternateScheduleSelectorTests` is a static class, but it // merely NESTS its `[TestFixture]`es and declares no test of its own, so it would fail the // sibling assertion above instead -- naming it here would have been a wrong example attached // to a right rule. (fixture.IsAbstract && !fixture.IsSealed).ShouldBeFalse( $"{fixture.Name} is named as the mutation fixture for {indexer.Name} but is an abstract " + "base class, so NUnit never runs it"); NeverRuns(fixture.GetCustomAttributes(inherit: true)).ShouldBeFalse( $"{fixture.Name} is named as the mutation fixture for {indexer.Name} but is [Explicit] or " + "[Ignore]d, so it never runs and proves nothing"); } } }