File: TestTriggerMap\SelectTestsAcceptanceTests.cs
Web Access
Project: src\tests\Infrastructure.Tests\Infrastructure.Tests.csproj (Infrastructure.Tests)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using Aspire.SelectTests;
using Xunit;
 
namespace Infrastructure.Tests.TestTriggerMap;
 
/// <summary>
/// Behavior spec for the <see cref="TestSelector"/> engine. These tests drive the selector with
/// small SYNTHETIC maps (a workspace <c>map.yml</c> + a fake matrix + fake project dirs), so they assert
/// the resolution mechanisms — conventions, overrides, ignore, Layer-1 attribution, the run-all
/// fallback, derived targets, and group expansion — without coupling to the contents of the real
/// <c>eng/github-ci/test-trigger-map.yml</c>. A thin set of real-map invariant smokes (computed from the
/// filesystem, never hardcoding project names) lives at the end; structural invariants of the real
/// map are covered by <see cref="TestTriggerMapTests"/>.
/// </summary>
public sealed class SelectTestsAcceptanceTests(ITestOutputHelper outputHelper) : IDisposable
{
    // A synthetic map exercising every section. Test projects referenced here are supplied (or
    // withheld) via the per-test matrix to drive the existence guard.
    private const string SyntheticMap = """
        version: 1
        groups:
          MIXED: [test:GroupTest, job:group-job]
          ONLYTESTS: [test:T1, test:T2]
          ONLYJOBS: [job:j1, job:j2]
          OUTER: [test:T1, INNER]
          INNER: [job:inner-job, test:T2]
          CYCA: [test:T1, CYCB]
          CYCB: [test:T2, CYCA]
          HASALL: [ALL]
        conventions:
          - pattern: tests/<name>/**
            target: test:<name>
          - pattern: src/Aspire.Hosting.<name>/**
            target: test:Aspire.Hosting.<name>.Tests
          - pattern: src/Components/<name>/**
            target: test:<name>.Tests
        ignore:
          - src/Shared/Inert/**
          - src/Both/**
        path_rules:
          - paths: [global.json]
            targets: [ALL]
          - paths: [src/Aspire.Hosting.Azure.*/**]
            targets: [test:Aspire.Hosting.Azure.Tests]
          - paths: [src/Aspire.Hosting.*/**]
            targets: [job:hostjob]
          - paths: [src/CuratedThing/**]
            targets: [job:cjob]
          - paths: [eng/installer/**]
            targets: [test:InstallerTests, job:installer]
          - paths: [src/Both/**]
            targets: [test:RealOne]
          - paths: [src/grp/**]
            targets: [MIXED]
          - paths: [src/grp2/**]
            targets: [MIXED]
          - paths: [src/allgrp/**]
            targets: [HASALL]
          - paths: [src/cyc/**]
            targets: [CYCA]
          - paths: [src/outer/**]
            targets: [OUTER]
          - paths: [src/bogus/**]
            targets: [BOGUS, test:RealOne]
        derived_targets:
          - tests: [test:CliTests, test:CliTestsTwo]
            targets: [job:cli-starter]
          - tests: [test:ChainA]
            targets: [test:ChainB]
          - tests: [test:ChainB]
            targets: [job:chain-job]
          - tests: [test:GrpTrigger]
            targets: [MIXED]
          - tests: [test:DerCycA]
            targets: [test:DerCycB]
          - tests: [test:DerCycB]
            targets: [test:DerCycA]
        affected_project_rules:
          - projects: [Aspire.ProjCli, Aspire.Managed]
            targets: [job:projjob]
          - projects: [Aspire.Hosting.Proj*]
            targets: [job:hostjob]
          - projects: [Aspire.ProjSelectsTest]
            targets: [test:RealOne]
          - projects: [Aspire.ProjChain]
            targets: [test:ChainA]
          - projects: [Aspire.ProjGroup]
            targets: [MIXED]
          - projects: [Aspire.ProjAll]
            targets: [ALL]
        """;
 
    // The matrix universe for the synthetic tests. Deliberately omits Aspire.Hosting.Ghost.Tests
    // and Aspire.Hosting.Azure.CosmosDB.Tests so the existence guard / override paths are exercised.
    private static readonly string[] s_matrix =
    [
        "Aspire.Hosting.Foo.Tests", "Foo.Tests", "Aspire.Hosting.Azure.Tests",
        "Aspire.Hosting.Azure.Kusto.Tests", "GroupTest", "T1", "T2", "InstallerTests",
        "CliTests", "CliTestsTwo", "ChainA", "ChainB", "RealOne", "GrpTrigger", "DerCycA", "DerCycB",
        "SelfProj", "Layer1Only",
    ];
 
    // Temp dirs holding synthetic maps are tracked here and removed in Dispose, so the ~40 tests in
    // this class don't each leave a directory behind. xUnit constructs one instance per test, so a
    // given test's workspace dirs are cleaned up when that instance is disposed.
    private readonly List<TemporaryWorkspace> _tempDirs = [];
 
    private string NewMapDir()
    {
        var workspace = TemporaryWorkspace.Create(outputHelper);
        _tempDirs.Add(workspace);
        return workspace.Path;
    }
 
    private TestSelector Selector(IEnumerable<string>? projectDirs = null)
    {
        var path = Path.Combine(NewMapDir(), "map.yml");
        File.WriteAllText(path, SyntheticMap);
        return new TestSelector(
            path,
            s_matrix.ToHashSet(StringComparer.Ordinal),
            (projectDirs ?? []).ToHashSet(StringComparer.Ordinal));
    }
 
    private SelectionResult Select(string[] files, string[]? layer1 = null, IEnumerable<string>? projectDirs = null)
        => Selector(projectDirs).Select(files, layer1 ?? [], new SelectorOptions());
 
    public void Dispose()
    {
        foreach (var dir in _tempDirs)
        {
            dir.Dispose();
        }
    }
 
    // --- A. Convention matching -------------------------------------------------------------
 
    [Fact]
    public void HostingConventionSelectsSameNamedTest()
    {
        var r = Select(["src/Aspire.Hosting.Foo/Bar.cs"]);
 
        Assert.Contains("Aspire.Hosting.Foo.Tests", r.TestProjects);
        Assert.False(r.SelectsAll);
    }
 
    [Fact]
    public void ComponentsConventionSelectsSameNamedTest()
    {
        var r = Select(["src/Components/Foo/Bar.cs"]);
 
        Assert.Contains("Foo.Tests", r.TestProjects);
        Assert.False(r.SelectsAll);
    }
 
    [Fact]
    public void ConventionExistenceGuardDropsTestNotInMatrix()
    {
        // No Aspire.Hosting.Ghost.Tests in the matrix; the dir is Layer-1-owned so it does not hit
        // the fallback — the point is the convention emits no phantom target.
        var r = Select(["src/Aspire.Hosting.Ghost/Bar.cs"], projectDirs: ["src/Aspire.Hosting.Ghost"]);
 
        Assert.DoesNotContain("Aspire.Hosting.Ghost.Tests", r.TestProjects);
        Assert.Empty(r.TestProjects);
        Assert.False(r.SelectsAll);
    }
 
    [Fact]
    public void ConventionGuardMissOnUnownedSrcFileForcesRunAll()
    {
        // The convention matches src/Components/Ghost/** -> test:Ghost.Tests, but Ghost.Tests is absent
        // from the matrix (existence guard drops it), no path_rule matches, and the file is not
        // Layer-1-owned. It must fall to the run-all fallback, not silently select nothing -- the
        // dangerous false-negative direction.
        var r = Select(["src/Components/Ghost/Bar.cs"]);
 
        Assert.True(r.SelectsAll);
    }
 
    [Fact]
    public void CoreHostingDirIsNotMatchedByConvention()
    {
        // src/Aspire.Hosting (no dotted suffix) must NOT match src/Aspire.Hosting.<name>. Owned so
        // it does not fall to run-all; the assertion is that no convention test is added.
        var r = Select(["src/Aspire.Hosting/Bar.cs"], projectDirs: ["src/Aspire.Hosting"]);
 
        Assert.Empty(r.TestProjects);
        Assert.False(r.SelectsAll);
    }
 
    [Fact]
    public void ConventionCapturesDottedSegment()
    {
        var r = Select(["src/Aspire.Hosting.Azure.Kusto/Bar.cs"]);
 
        Assert.Contains("Aspire.Hosting.Azure.Kusto.Tests", r.TestProjects);
    }
 
    [Fact]
    public void ConventionIsAdditiveOverLayer1()
    {
        var r = Select(["src/Components/Foo/Bar.cs"], layer1: ["Layer1Only"]);
 
        Assert.Contains("Foo.Tests", r.TestProjects);
        Assert.Contains("Layer1Only", r.TestProjects);
    }
 
    [Fact]
    public void ConventionMatchPreventsRunAllFallback()
    {
        // Not Layer-1-owned and not ignored: only the convention match keeps this src file out of
        // the run-all fallback.
        var r = Select(["src/Components/Foo/Bar.cs"]);
 
        Assert.False(r.SelectsAll);
        Assert.Contains("Foo.Tests", r.TestProjects);
        Assert.DoesNotContain("src/Components/Foo/Bar.cs", r.UnmatchedFiles);
    }
 
    [Fact]
    public void ConventionMatchesDeeplyNestedFile()
    {
        var r = Select(["src/Components/Foo/a/b/c/Deep.cs"]);
 
        Assert.Contains("Foo.Tests", r.TestProjects);
    }
 
    // --- B. Convention overrides ------------------------------------------------------------
 
    [Fact]
    public void OverrideCoversConventionMissDir()
    {
        // No Aspire.Hosting.Azure.CosmosDB.Tests in the matrix; the override maps the Azure family
        // to the aggregate Azure test.
        var r = Select(["src/Aspire.Hosting.Azure.CosmosDB/Bar.cs"]);
 
        Assert.Contains("Aspire.Hosting.Azure.Tests", r.TestProjects);
        Assert.DoesNotContain("Aspire.Hosting.Azure.CosmosDB.Tests", r.TestProjects);
        Assert.False(r.SelectsAll);
    }
 
    [Fact]
    public void OverrideCoexistsWithConvention()
    {
        var r = Select(["src/Aspire.Hosting.Azure.Kusto/Bar.cs"]);
 
        Assert.Contains("Aspire.Hosting.Azure.Kusto.Tests", r.TestProjects); // convention
        Assert.Contains("Aspire.Hosting.Azure.Tests", r.TestProjects);       // override
    }
 
    // --- C. Ignore --------------------------------------------------------------------------
 
    [Fact]
    public void IgnoredFileContributesNoTargets()
    {
        var r = Select(["src/Shared/Inert/Thing.cs"]);
 
        Assert.Empty(r.TestProjects);
        Assert.Empty(r.Jobs);
    }
 
    [Fact]
    public void IgnoredSrcFileDoesNotForceRunAllOrReportUnmatched()
    {
        // Not Layer-1-owned, but ignored -> must not escalate to ALL and must not be flagged.
        var r = Select(["src/Shared/Inert/Thing.cs"]);
 
        Assert.False(r.SelectsAll);
        Assert.DoesNotContain("src/Shared/Inert/Thing.cs", r.UnmatchedFiles);
    }
 
    [Fact]
    public void IgnoreDoesNotSuppressAnExplicitMatch()
    {
        // src/Both/** is in BOTH the ignore list and a loose_file_deps rule. Ignore only prevents
        // the fallback; the explicit rule's target still applies.
        var r = Select(["src/Both/Thing.cs"]);
 
        Assert.Contains("RealOne", r.TestProjects);
        Assert.False(r.SelectsAll);
    }
 
    // --- D. Layer-1 attribution & run-all fallback ------------------------------------------
 
    [Fact]
    public void OrphanSrcFileForcesRunAll()
    {
        // Not owned, not matched, not ignored, under src/ -> the run-all fallback.
        var r = Select(["src/Orphan/Thing.cs"]);
 
        Assert.True(r.SelectsAll);
        Assert.NotNull(r.EscalationReason);
        Assert.Contains("src/Orphan/Thing.cs", r.UnmatchedFiles);
    }
 
    [Fact]
    public void Layer1OwnedSrcFileDoesNotForceRunAll()
    {
        // Same shape as the orphan, but the dir is a project in the (fake) slnx -> Layer-1-owned.
        var r = Select(["src/OwnedProj/Thing.cs"], projectDirs: ["src/OwnedProj"]);
 
        Assert.False(r.SelectsAll);
        Assert.Empty(r.TestProjects);
        Assert.DoesNotContain("src/OwnedProj/Thing.cs", r.UnmatchedFiles);
    }
 
    [Fact]
    public void Layer1OwnedPrefixRequiresPathBoundary()
    {
        // "src/OwnedProj" must not own the sibling "src/OwnedProjExtra/..." -- ownership matches on a
        // trailing-separator prefix. A bare StartsWith would falsely own the sibling and suppress its
        // run-all fallback (a silent under-selection); this is the regression that guard prevents.
        var r = Select(["src/OwnedProjExtra/Thing.cs"], projectDirs: ["src/OwnedProj"]);
 
        Assert.True(r.SelectsAll);
        Assert.Contains("src/OwnedProjExtra/Thing.cs", r.UnmatchedFiles);
    }
 
    [Fact]
    public void LeftoverUnmatchedFileForcesRunAll()
    {
        // Any changed file that is not Layer-1-owned, not ignored, and matched by no rule forces ALL
        // (fail-safe), regardless of where it lives -- not just under src/**. In production the
        // prefilter has already dropped changes that need no CI, so only genuinely-unmapped files reach
        // this fallback.
        var r = Select(["docs/architecture/notes.md"]);
 
        Assert.True(r.SelectsAll);
        Assert.Contains("docs/architecture/notes.md", r.UnmatchedFiles);
    }
 
    [Fact]
    public void Layer1AffectedProjectsAreUnionedIn()
    {
        var r = Select(["src/OwnedProj/Thing.cs"], layer1: ["T1", "T2"], projectDirs: ["src/OwnedProj"]);
 
        Assert.Contains("T1", r.TestProjects);
        Assert.Contains("T2", r.TestProjects);
    }
 
    [Fact]
    public void RunAllGlobForcesAllWithNoUnmatched()
    {
        var r = Select(["global.json"]);
 
        Assert.True(r.SelectsAll);
        Assert.Empty(r.UnmatchedFiles);
    }
 
    // --- E. Derived targets (fixpoint) ------------------------------------------------------
 
    [Fact]
    public void DerivedTargetFiresForLayer1SelectedTest()
    {
        var r = Select(["src/OwnedProj/Thing.cs"], layer1: ["CliTests"], projectDirs: ["src/OwnedProj"]);
 
        Assert.Contains("job:cli-starter", r.Jobs);
    }
 
    [Fact]
    public void DerivedTargetFiresForConventionSelectedTest()
    {
        // tests/<X>/** -> test:<X> (convention); CliTests then derives -> cli-starter.
        var r = Select(["tests/CliTests/Foo.cs"]);
 
        Assert.Contains("CliTests", r.TestProjects);
        Assert.Contains("job:cli-starter", r.Jobs);
    }
 
    [Fact]
    public void DerivedRuleFiresForAnyTriggerTestInItsList()
    {
        // The cli-starter rule lists two trigger tests; selecting the second one must fire it too.
        var r = Select(["src/OwnedProj/Thing.cs"], layer1: ["CliTestsTwo"], projectDirs: ["src/OwnedProj"]);
 
        Assert.Contains("job:cli-starter", r.Jobs);
    }
 
    [Fact]
    public void TestWithNoDerivedRuleAddsNothing()
    {
        var r = Select(["src/OwnedProj/Thing.cs"], layer1: ["T1"], projectDirs: ["src/OwnedProj"]);
 
        Assert.Empty(r.Jobs);
    }
 
    [Fact]
    public void DerivedTargetsReachFixpointThroughTestChain()
    {
        // ChainA -> ChainB -> job:chain-job.
        var r = Select(["src/OwnedProj/Thing.cs"], layer1: ["ChainA"], projectDirs: ["src/OwnedProj"]);
 
        Assert.Contains("ChainB", r.TestProjects);
        Assert.Contains("job:chain-job", r.Jobs);
    }
 
    [Fact]
    public void DerivedTargetCycleTerminates()
    {
        // DerCycA -> DerCycB -> DerCycA. Must terminate with both selected.
        var r = Select(["src/OwnedProj/Thing.cs"], layer1: ["DerCycA"], projectDirs: ["src/OwnedProj"]);
 
        Assert.Contains("DerCycA", r.TestProjects);
        Assert.Contains("DerCycB", r.TestProjects);
    }
 
    [Fact]
    public void DerivedPassIsNoOpUnderSelectsAll()
    {
        var r = Select(["global.json"], layer1: ["CliTests"]);
 
        Assert.True(r.SelectsAll);
        // Under ALL, Jobs is the full vocabulary; cli-starter is present because it is a known token,
        // not because the derived pass ran.
        Assert.True(r.TestProjects.SetEquals(s_matrix));
    }
 
    // --- F. Composition / switches ----------------------------------------------------------
 
    [Fact]
    public void MultipleFilesUnionTheirTargets()
    {
        var r = Select(["src/Components/Foo/Bar.cs", "eng/installer/x.ps1"]);
 
        Assert.Contains("Foo.Tests", r.TestProjects);
        Assert.Contains("InstallerTests", r.TestProjects);
        Assert.Contains("job:installer", r.Jobs);
    }
 
    [Fact]
    public void KillSwitchForcesAll()
    {
        var r = Selector().Select(["src/Components/Foo/Bar.cs"], [], new SelectorOptions(ForceAll: true));
 
        Assert.True(r.SelectsAll);
        Assert.True(r.TestProjects.SetEquals(s_matrix));
    }
 
    [Fact]
    public void SelectsAllExpandsToFullMatrixAndAllJobTokens()
    {
        var r = Select(["global.json"]);
 
        Assert.True(r.TestProjects.SetEquals(s_matrix));
        // Every job token referenced anywhere in the synthetic map is part of the ALL expansion.
        Assert.Contains("job:installer", r.Jobs);
        Assert.Contains("job:hostjob", r.Jobs);
        Assert.Contains("job:cli-starter", r.Jobs);
        Assert.Contains("job:group-job", r.Jobs);
        Assert.Contains("job:projjob", r.Jobs);
    }
 
    [Fact]
    public void EmptyChangeSelectsNothing()
    {
        var r = Select([]);
 
        Assert.False(r.SelectsAll);
        Assert.Empty(r.TestProjects);
        Assert.Empty(r.Jobs);
    }
 
    // --- F2. Cause attribution (the "why") --------------------------------------------------
 
    [Fact]
    public void PathRuleCauseCarriesTheChangedFile()
    {
        var r = Select(["eng/installer/manifest.yml"]);
 
        var cause = Assert.Single(r.TestCauses["InstallerTests"]);
        Assert.Equal(CauseKind.PathRule, cause.Kind);
        Assert.Equal("eng/installer/manifest.yml", cause.Trigger);
    }
 
    [Fact]
    public void ConventionCauseCarriesTheChangedFile()
    {
        var r = Select(["src/Components/Foo/Bar.cs"]);
 
        var cause = Assert.Single(r.TestCauses["Foo.Tests"]);
        Assert.Equal(CauseKind.Convention, cause.Kind);
        Assert.Equal("src/Components/Foo/Bar.cs", cause.Trigger);
    }
 
    [Fact]
    public void Layer1GraphCauseNamesTheAffectedTestProject()
    {
        var r = Select(["src/OwnedProj/Thing.cs"], layer1: ["Layer1Only"], projectDirs: ["src/OwnedProj"]);
 
        var cause = Assert.Single(r.TestCauses["Layer1Only"]);
        Assert.Equal(CauseKind.Layer1Graph, cause.Kind);
        Assert.Equal("Layer1Only", cause.Trigger);
    }
 
    // The Layer 1 cause must carry the full graph decision path -- the seed changed file followed by the
    // reverse-dependency project chain -- so the summary can show HOW the change reached the test, not
    // just THAT it did. Failure mode: dropping the path collapses the summary back to a bare name.
    [Fact]
    public void Layer1GraphCauseCarriesTheGraphPath()
    {
        var paths = new Dictionary<string, AffectedPath>(StringComparer.Ordinal)
        {
            ["Layer1Only"] = new AffectedPath("src/OwnedProj/Thing.cs", ["Aspire.OwnedProj", "Aspire.Mid", "Layer1Only"]),
        };
 
        var r = Selector(["src/OwnedProj"]).Select(
            ["src/OwnedProj/Thing.cs"],
            ["Layer1Only"],
            new SelectorOptions(),
            layer1AttributedPaths: null,
            layer1Paths: paths);
 
        var cause = Assert.Single(r.TestCauses["Layer1Only"]);
        Assert.Equal(CauseKind.Layer1Graph, cause.Kind);
        Assert.NotNull(cause.Path);
        Assert.Equal(
            new[] { "src/OwnedProj/Thing.cs", "Aspire.OwnedProj", "Aspire.Mid", "Layer1Only" },
            cause.Path);
    }
 
    [Fact]
    public void AffectedProjectCauseNamesTheMatchedProject()
    {
        var r = Select(["src/OwnedProj/Thing.cs"], layer1: ["Aspire.ProjCli"], projectDirs: ["src/OwnedProj"]);
 
        var cause = Assert.Single(r.JobCauses["job:projjob"]);
        Assert.Equal(CauseKind.AffectedProject, cause.Kind);
        Assert.Equal("Aspire.ProjCli", cause.Trigger);
    }
 
    [Fact]
    public void DerivedJobCauseNamesTheTriggeringTestProject()
    {
        // A job pulled in purely because a test project is selected: tests/CliTests/** -> test:CliTests
        // (convention) -> job:cli-starter (derived_targets). The cause is DerivedFromTest naming CliTests
        // (rendered "selected test CliTests" in the job-reasons cell).
        var r = Select(["tests/CliTests/Foo.cs"]);
 
        var cause = Assert.Single(r.JobCauses["job:cli-starter"]);
        Assert.Equal(CauseKind.DerivedFromTest, cause.Kind);
        Assert.Equal("CliTests", cause.Trigger);
    }
 
    [Fact]
    public void ItemSelectedByTwoTriggersRecordsBothCauses()
    {
        // job:installer is hit by two distinct changed files; both must be attributed.
        var r = Select(["eng/installer/a.yml", "src/OwnedProj/x.cs"], layer1: ["Aspire.ProjCli"], projectDirs: ["src/OwnedProj"]);
 
        // job:projjob comes from the affected project; job:installer from the path rule. Each has its
        // own distinct cause kind.
        Assert.Equal(CauseKind.PathRule, Assert.Single(r.JobCauses["job:installer"]).Kind);
        Assert.Equal(CauseKind.AffectedProject, Assert.Single(r.JobCauses["job:projjob"]).Kind);
    }
 
    [Fact]
    public void SelectsAllTracksNoPerItemCauses()
    {
        var r = Select(["global.json"]);
 
        Assert.True(r.SelectsAll);
        Assert.Empty(r.TestCauses);
        Assert.Empty(r.JobCauses);
        Assert.NotNull(r.EscalationReason);
    }
 
    // --- G. test_self / curated_jobs / loose_file_deps --------------------------------------
 
    [Fact]
    public void TestSelfChangeRunsThatTest()
    {
        var r = Select(["tests/SelfProj/SomeTest.cs"]);
 
        Assert.Contains("SelfProj", r.TestProjects);
    }
 
    [Fact]
    public void FileMatchingBothJobGlobAndConventionGetsBoth()
    {
        // src/Aspire.Hosting.Foo/** matches the convention AND the src/Aspire.Hosting.*/** job glob.
        var r = Select(["src/Aspire.Hosting.Foo/Bar.cs"]);
 
        Assert.Contains("Aspire.Hosting.Foo.Tests", r.TestProjects);
        Assert.Contains("job:hostjob", r.Jobs);
    }
 
    [Fact]
    public void LooseFileDepSelectsTestAndJob()
    {
        var r = Select(["eng/installer/manifest.yml"]);
 
        Assert.Contains("InstallerTests", r.TestProjects);
        Assert.Contains("job:installer", r.Jobs);
    }
 
    // --- I. Named-group mechanism -----------------------------------------------------------
 
    [Fact]
    public void GroupExpandsToMixedMembers()
    {
        var r = Select(["src/grp/Thing.cs"]);
 
        Assert.Contains("GroupTest", r.TestProjects);
        Assert.Contains("job:group-job", r.Jobs);
    }
 
    [Fact]
    public void SameGroupFromTwoRulesIsDeduped()
    {
        var r = Select(["src/grp/A.cs", "src/grp2/B.cs"]);
 
        Assert.Contains("GroupTest", r.TestProjects);
        Assert.Contains("job:group-job", r.Jobs);
    }
 
    [Fact]
    public void GroupExpandsFromDerivedRule()
    {
        var r = Select(["src/OwnedProj/Thing.cs"], layer1: ["GrpTrigger"], projectDirs: ["src/OwnedProj"]);
 
        Assert.Contains("GroupTest", r.TestProjects);
        Assert.Contains("job:group-job", r.Jobs);
    }
 
    [Fact]
    public void NestedGroupsExpandRecursively()
    {
        // OUTER -> [test:T1, INNER]; INNER -> [job:inner-job, test:T2]. The inner group's members
        // must be reached through the recursive expansion.
        var r = Select(["src/outer/Thing.cs"]);
 
        Assert.Contains("T1", r.TestProjects);
        Assert.Contains("T2", r.TestProjects);
        Assert.Contains("job:inner-job", r.Jobs);
    }
 
    [Fact]
    public void CyclicGroupExpansionTerminates()
    {
        // CYCA -> [test:T1, CYCB]; CYCB -> [test:T2, CYCA]. Must terminate with both tests.
        var r = Select(["src/cyc/Thing.cs"]);
 
        Assert.Contains("T1", r.TestProjects);
        Assert.Contains("T2", r.TestProjects);
    }
 
    [Fact]
    public void UnknownTargetTokenIsIgnored()
    {
        // The rule targets [BOGUS, test:RealOne]; BOGUS is neither a group nor test:/job: -> ignored.
        var r = Select(["src/bogus/Thing.cs"]);
 
        Assert.Contains("RealOne", r.TestProjects);
        Assert.DoesNotContain("BOGUS", r.TestProjects);
    }
 
    [Fact]
    public void GroupContainingAllForcesSelectsAll()
    {
        var r = Select(["src/allgrp/Thing.cs"]);
 
        Assert.True(r.SelectsAll);
    }
 
    // --- J. project_rules (affected-project -> targets) --------------------------------------
 
    [Fact]
    public void ProjectRuleFiresForAffectedProductionProject()
    {
        // An affected production project (not a matrix test) drives the rule's job.
        var r = Select([], layer1: ["Aspire.ProjCli"]);
 
        Assert.Contains("job:projjob", r.Jobs);
    }
 
    [Fact]
    public void ProjectRuleProductionNameIsNotSelectedAsATest()
    {
        // The production name is matched by project_rules but is not a matrix project, so it must
        // not leak into the selected test set.
        var r = Select([], layer1: ["Aspire.ProjCli"]);
 
        Assert.DoesNotContain("Aspire.ProjCli", r.TestProjects);
    }
 
    [Fact]
    public void ProjectRuleMatchesByNameGlob()
    {
        // Aspire.Hosting.Proj* matches an affected hosting project by NAME (not path).
        var r = Select([], layer1: ["Aspire.Hosting.ProjRedis"]);
 
        Assert.Contains("job:hostjob", r.Jobs);
    }
 
    [Fact]
    public void ProjectRuleFiresForAnyProjectInItsList()
    {
        // The projjob rule lists two projects; the second one must fire it too.
        var r = Select([], layer1: ["Aspire.Managed"]);
 
        Assert.Contains("job:projjob", r.Jobs);
    }
 
    [Fact]
    public void ProjectRuleIsAdditiveWithLayer1Tests()
    {
        // A real matrix test in the affected set is still selected alongside the project rule's job.
        var r = Select([], layer1: ["Aspire.ProjCli", "T1"]);
 
        Assert.Contains("T1", r.TestProjects);
        Assert.Contains("job:projjob", r.Jobs);
    }
 
    [Fact]
    public void ProjectRuleSelectedTestFeedsDerivedTargets()
    {
        // Aspire.ProjChain -> test:ChainA (project_rule); ChainA -> ChainB -> job:chain-job (derived).
        var r = Select([], layer1: ["Aspire.ProjChain"]);
 
        Assert.Contains("ChainA", r.TestProjects);
        Assert.Contains("ChainB", r.TestProjects);
        Assert.Contains("job:chain-job", r.Jobs);
    }
 
    [Fact]
    public void ProjectRuleExpandsGroup()
    {
        var r = Select([], layer1: ["Aspire.ProjGroup"]);
 
        Assert.Contains("GroupTest", r.TestProjects);
        Assert.Contains("job:group-job", r.Jobs);
    }
 
    [Fact]
    public void ProjectRuleCanForceAll()
    {
        var r = Select([], layer1: ["Aspire.ProjAll"]);
 
        Assert.True(r.SelectsAll);
    }
 
    [Fact]
    public void UnmatchedAffectedProjectAddsNothing()
    {
        var r = Select([], layer1: ["Aspire.Unrelated"]);
 
        Assert.False(r.SelectsAll);
        Assert.Empty(r.TestProjects);
        Assert.Empty(r.Jobs);
    }
 
    // Layer 1 reports the full affected set, which can include tests/ projects that are NOT in the
    // runnable matrix (shared fixtures/helpers like a TestFixtures or testproject project). Those names
    // are intersected with the matrix before selection, so they must never be selected as a test (only
    // an affected_project_rule may reference such a name by name). Failure mode: a non-runnable project
    // leaking into the matrix.
    [Fact]
    public void Layer1AffectedNonMatrixTestNameIsNotSelected()
    {
        var r = Select([], layer1: ["TestFixtures.Shared", "testproject"]);
 
        Assert.False(r.SelectsAll);
        Assert.Empty(r.TestProjects);
        Assert.Empty(r.Jobs);
    }
 
    // Regression (production-only affected_project_rules): Layer 1 reports affected production AND test
    // projects. affected_project_rules key off project NAME globs and must match only PRODUCTION names.
    // A matrix test project ("Aspire.Hosting.Foo.Tests") matches a production glob ("Aspire.Hosting*"),
    // so without the production-only filter a TEST-ONLY change would spuriously fire that rule's
    // production jobs (ats-diffs / extension-e2e / typescript-api-compat / deployment-e2e).
    [Fact]
    public void AffectedProjectRulesMatchProductionProjectsNotTestProjects()
    {
        const string map = """
            version: 1
            affected_project_rules:
              - projects: [Aspire.Hosting*]
                targets: [job:prodjob]
            """;
        var mapPath = Path.Combine(NewMapDir(), "map.yml");
        File.WriteAllText(mapPath, map);
        var matrix = new[] { "Aspire.Hosting.Foo.Tests" }.ToHashSet(StringComparer.Ordinal);
        var selector = new TestSelector(mapPath, matrix, []);
 
        // A test-only change: only the test project is affected. It is still selected as a test (Layer 1
        // intersection), but must NOT drive the production rule's job.
        var testOnly = selector.Select([], ["Aspire.Hosting.Foo.Tests"], new SelectorOptions());
        Assert.Contains("Aspire.Hosting.Foo.Tests", testOnly.TestProjects);
        Assert.DoesNotContain("job:prodjob", testOnly.Jobs);
 
        // A production project of the same name prefix DOES drive the rule.
        var prod = selector.Select([], ["Aspire.Hosting.Foo"], new SelectorOptions());
        Assert.Contains("job:prodjob", prod.Jobs);
    }
 
    // --- H. Real-map invariant smoke (computed from the filesystem; no hardcoded names) ------
 
    // The prefilter reads eng/github-ci/ci-skip-entirely-patterns.txt at runtime and matches it with the
    // SAME glob->regex the check-changed-files action uses (ported in ChangedFileFilter). This pins that
    // port (docs/.slnf/scripts get dropped) and the keep_routed carve-outs (selector-routed files are NOT
    // dropped even though the patterns file lists them).
    [Fact]
    public void PrefilterReadsPatternsFileAndHonorsKeepRouted()
    {
        var mapPath = Path.Combine(RepoRoot.Path, "eng", "github-ci", "test-trigger-map.yml");
        var map = TriggerMap.Load(mapPath);
        var filter = ChangedFileFilter.Create(RepoRoot.Path, map.Prefilter);
 
        // Dropped: entries from the patterns file (action glob syntax: **.md, localhive.*, ...).
        Assert.True(filter.IsExcluded("docs/anything.md"));
        Assert.True(filter.IsExcluded("src/Aspire.Cli/README.md"));     // **.md matches at any depth
        Assert.True(filter.IsExcluded(".agents/skills/foo/SKILL.md"));
        Assert.True(filter.IsExcluded("Aspire-Core.slnf"));
        Assert.True(filter.IsExcluded("localhive.ps1"));                 // localhive.* -> localhive\.[^/]*
 
        // Repository metadata with no build/test impact: a PR touching only these must not force the
        // full matrix via the run-all fallback (the reason they are in the skip-gate patterns file).
        Assert.True(filter.IsExcluded(".gitignore"));                    // repo-ROOT .gitignore (anchored)
        Assert.True(filter.IsExcluded(".github/CODEOWNERS"));
        Assert.True(filter.IsExcluded(".github/ISSUE_TEMPLATE/10_bug_report.yml"));
 
        // Safety carve-outs: NOT dropped, because they can change build/test outcomes -- nested .gitignore
        // files are shipped CLI-template assets (Layer 1 / conventions route them to their projects), and
        // .editorconfig / .gitattributes affect the build and checkout.
        Assert.False(filter.IsExcluded("src/Aspire.Cli/Templating/Templates/ts-starter/.gitignore"));
        Assert.False(filter.IsExcluded(".editorconfig"));
        Assert.False(filter.IsExcluded(".gitattributes"));
 
        // Kept: real source the patterns file does not list.
        Assert.False(filter.IsExcluded("src/Aspire.Cli/Program.cs"));
 
        // keep_routed carve-outs: listed by the patterns file but routed by the selector -> NOT dropped.
        Assert.False(filter.IsExcluded(".github/workflows/backport.yml"));
        Assert.False(filter.IsExcluded("eng/pipelines/azure-pipelines-public.yml"));
        Assert.False(filter.IsExcluded("eng/github-ci/ci-skip-entirely-patterns.txt"));
    }
 
    // A src/** file that no Layer 2 rule matches and that is not under a project directory normally hits
    // the run-all fallback. But when Layer 1 attributed it (a link-compiled src/Shared / tests/Shared
    // file), it is Layer-1-owned and must NOT force ALL -- this is why those dirs need no `ignore` entry.
    [Fact]
    public void Layer1AttributedFileIsOwnedAndDoesNotForceRunAll()
    {
        const string shared = "src/Shared/Foo.cs";
 
        // No attribution, no project dirs -> the src/** fallback escalates to ALL.
        var withoutAttribution = Select([shared]);
        Assert.True(withoutAttribution.SelectsAll);
 
        // Same file, but reported as Layer-1-attributed -> owned, so no fallback and no ALL.
        var withAttribution = Selector().Select(
            [shared], [], new SelectorOptions(), new HashSet<string>(StringComparer.Ordinal) { shared });
        Assert.False(withAttribution.SelectsAll);
        Assert.Empty(withAttribution.TestProjects);
    }
 
    [Fact]
    public void RealMapLoadsAndConventionSelectsAComponentsTestWithoutSelectingAll()
    {
        var mapPath = Path.Combine(RepoRoot.Path, "eng", "github-ci", "test-trigger-map.yml");
        var matrix = EnumerateMatrixTestProjects();
        var projectDirs = LoadProjectDirectories();
        var selector = new TestSelector(mapPath, matrix, projectDirs);
 
        // Pick a real src/Components/<dir> whose same-named test exists; the convention must select
        // exactly that test name (derived from the dir, not hardcoded) and must not force ALL.
        var (componentDir, expectedTest) = FirstComponentWithSameNamedTest(matrix);
 
        var r = selector.Select([$"{componentDir}/__probe__.cs"], [], new SelectorOptions());
 
        Assert.Contains(expectedTest, r.TestProjects);
        Assert.False(r.SelectsAll);
    }
 
    // An integration's checked-in *.ats.txt baseline tracks its exported [AspireExport] surface -- the
    // same surface the per-language polyglot playground scripts regenerate and compile
    // (aspire restore --apphost over tests/PolyglotAppHosts/<integration>/<lang>). A change to that
    // baseline must therefore run BOTH typescript-api-compat (baseline diff) AND polyglot (regenerate +
    // compile in every language), so a breaking surface change is caught even if the author did not also
    // touch the tests/PolyglotAppHosts fixtures. The baseline is not a compiled item, so Layer 1 is blind
    // to it: routing here is the only thing that fires polyglot. Run with --skip-layer1 semantics (no
    // Layer 1 affected set) to prove the curated layer alone carries both targets.
    [Fact]
    public void RealMapIntegrationAtsBaselineChangeRunsTypeScriptApiCompatAndPolyglot()
    {
        var mapPath = Path.Combine(RepoRoot.Path, "eng", "github-ci", "test-trigger-map.yml");
        var selector = new TestSelector(mapPath, EnumerateMatrixTestProjects(), LoadProjectDirectories());
 
        var atsBaseline = FirstIntegrationAtsBaselineWithPolyglotFixture();
 
        var r = selector.Select([atsBaseline], [], new SelectorOptions());
 
        Assert.False(r.SelectsAll);
        Assert.Contains("job:typescript-api-compat", r.Jobs);
        Assert.Contains("job:polyglot", r.Jobs);
    }
 
    [Fact]
    public void RealMapBlazorRuntimeAssetChangeRunsPackageAndPolyglotRegressions()
    {
        var mapPath = Path.Combine(RepoRoot.Path, "eng", "github-ci", "test-trigger-map.yml");
        var selector = new TestSelector(mapPath, EnumerateMatrixTestProjects(), LoadProjectDirectories());
 
        var r = selector.Select(
            ["src/Aspire.Hosting.Blazor/targets/GenerateScripts.targets"],
            [],
            new SelectorOptions());
 
        Assert.False(r.SelectsAll);
        Assert.Contains("Aspire.Hosting.Tests", r.TestProjects);
        Assert.Contains("Aspire.Cli.EndToEnd.Tests", r.TestProjects);
        Assert.Contains("job:polyglot", r.Jobs);
    }
 
    // A real src/Aspire.Hosting*/api/<name>.ats.txt baseline for a genuine INTEGRATION (not the
    // codegen engine itself) whose integration also has a tests/PolyglotAppHosts/<name> fixture, so the
    // change exercises the polyglot playground for that integration's exported surface. Computed from the
    // filesystem (never hardcoded names) so it survives integrations being added or removed. The engine
    // projects below are excluded because they reach polyglot through affected_project_rules (their
    // project NAME), not through the *.ats.txt path rule under test here -- so picking one would not prove
    // the path rule fires polyglot.
    private static string FirstIntegrationAtsBaselineWithPolyglotFixture()
    {
        var srcDir = Path.Combine(RepoRoot.Path, "src");
        var polyglotRoot = Path.Combine(RepoRoot.Path, "tests", "PolyglotAppHosts");
 
        static bool IsEngineProject(string name) =>
            name is "Aspire.Hosting" or "Aspire.Hosting.RemoteHost"
            || name.StartsWith("Aspire.Hosting.CodeGeneration.", StringComparison.Ordinal);
 
        foreach (var projectDir in Directory.EnumerateDirectories(srcDir, "Aspire.Hosting*").Order(StringComparer.Ordinal))
        {
            var name = Path.GetFileName(projectDir);
            if (IsEngineProject(name))
            {
                continue;
            }
 
            var baseline = Path.Combine(projectDir, "api", $"{name}.ats.txt");
            if (File.Exists(baseline) && Directory.Exists(Path.Combine(polyglotRoot, name)))
            {
                return $"src/{name}/api/{name}.ats.txt";
            }
        }
 
        throw new InvalidOperationException("No integration src/Aspire.Hosting*/api/<name>.ats.txt with a matching tests/PolyglotAppHosts/<name> fixture was found.");
    }
 
    private static (string Dir, string Test) FirstComponentWithSameNamedTest(IReadOnlyCollection<string> matrix)
    {
        var componentsRoot = Path.Combine(RepoRoot.Path, "src", "Components");
        foreach (var dir in Directory.EnumerateDirectories(componentsRoot).Select(Path.GetFileName).Order(StringComparer.Ordinal))
        {
            var test = $"{dir}.Tests";
            if (matrix.Contains(test))
            {
                return ($"src/Components/{dir}", test);
            }
        }
 
        throw new InvalidOperationException("No src/Components/<dir> with a same-named test project was found.");
    }
 
    private static IReadOnlyCollection<string> EnumerateMatrixTestProjects()
    {
        var testsDir = Path.Combine(RepoRoot.Path, "tests");
        return Directory.EnumerateDirectories(testsDir)
            .Select(Path.GetFileName)
            .Where(name => name is not null && File.Exists(Path.Combine(testsDir, name!, $"{name}.csproj")))
            .Select(name => name!)
            .ToHashSet(StringComparer.Ordinal);
    }
 
    private static IReadOnlyCollection<string> LoadProjectDirectories()
    {
        var slnx = File.ReadAllText(Path.Combine(RepoRoot.Path, "Aspire.slnx"));
        return System.Text.RegularExpressions.Regex.Matches(slnx, "Path=\"([^\"]+\\.csproj)\"")
            .Select(m => m.Groups[1].Value.Replace('\\', '/'))
            .Select(p => p.Contains('/', StringComparison.Ordinal) ? p[..p.LastIndexOf('/')] : p)
            .Where(d => d.Length > 0)
            .ToHashSet(StringComparer.Ordinal);
    }
}