From 7cca8618ea81037290c6d29ed16664a3aeac315c Mon Sep 17 00:00:00 2001 From: Ryan Nowak Date: Wed, 6 Sep 2017 23:12:21 -0700 Subject: [PATCH] Change notifications for the project manager There's still nothing processing the notifications in the background. This is all the plumbing for dirty checking and publishing updates. --- .../ProjectSystem/DefaultProjectSnapshot.cs | 83 +++++ .../DefaultProjectSnapshotManager.cs | 63 +++- .../MvcExtensibilityConfiguration.cs | 24 ++ .../ProjectExtensibilityAssembly.cs | 22 +- .../ProjectExtensibilityConfiguration.cs | 12 +- .../ProjectSystem/ProjectSnapshot.cs | 4 + .../ProjectSnapshotManagerBase.cs | 2 + .../ProjectSnapshotUpdateContext.cs | 24 ++ .../DefaultProjectSnapshotManagerTest.cs | 299 ++++++++++++++++++ 9 files changed, 523 insertions(+), 10 deletions(-) create mode 100644 src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/ProjectSnapshotUpdateContext.cs create mode 100644 test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/ProjectSystem/DefaultProjectSnapshotManagerTest.cs diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/DefaultProjectSnapshot.cs b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/DefaultProjectSnapshot.cs index c8429a777d..ad43248fab 100644 --- a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/DefaultProjectSnapshot.cs +++ b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/DefaultProjectSnapshot.cs @@ -5,6 +5,14 @@ using System; namespace Microsoft.CodeAnalysis.Razor.ProjectSystem { + // All of the public state of this is immutable - we create a new instance and notify subscribers + // when it changes. + // + // However we use the private state to track things like dirty/clean. + // + // See the private constructors... When we update the snapshot we either are processing a Workspace + // change (Project) or updating the computed state (ProjectSnapshotUpdateContext). We don't do both + // at once. internal class DefaultProjectSnapshot : ProjectSnapshot { public DefaultProjectSnapshot(Project underlyingProject) @@ -17,6 +25,81 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem UnderlyingProject = underlyingProject; } + private DefaultProjectSnapshot(Project underlyingProject, DefaultProjectSnapshot other) + { + if (underlyingProject == null) + { + throw new ArgumentNullException(nameof(underlyingProject)); + } + + if (other == null) + { + throw new ArgumentNullException(nameof(other)); + } + + UnderlyingProject = underlyingProject; + + ComputedVersion = other.ComputedVersion; + Configuration = other.Configuration; + } + + private DefaultProjectSnapshot(ProjectSnapshotUpdateContext update, DefaultProjectSnapshot other) + { + if (update == null) + { + throw new ArgumentNullException(nameof(update)); + } + + if (other == null) + { + throw new ArgumentNullException(nameof(other)); + } + + UnderlyingProject = other.UnderlyingProject; + + ComputedVersion = update.UnderlyingProject.Version; + Configuration = update.Configuration; + } + + public override ProjectExtensibilityConfiguration Configuration { get; } + public override Project UnderlyingProject { get; } + + // This is the version that the computed state is based on. + public VersionStamp? ComputedVersion { get; set; } + + // We know the project is dirty if we don't have a computed result, or it was computed for a different version. + // Since the PSM updates the snapshots synchronously, the snapshot can never be older than the computed state. + public bool IsDirty => ComputedVersion == null || ComputedVersion.Value != UnderlyingProject.Version; + + public DefaultProjectSnapshot WithProjectChange(Project project) + { + if (project == null) + { + throw new ArgumentNullException(nameof(project)); + } + + return new DefaultProjectSnapshot(project, this); + } + + public DefaultProjectSnapshot WithProjectChange(ProjectSnapshotUpdateContext update) + { + if (update == null) + { + throw new ArgumentNullException(nameof(update)); + } + + return new DefaultProjectSnapshot(update, this); + } + + public bool HasChangesComparedTo(ProjectSnapshot original) + { + if (original == null) + { + throw new ArgumentNullException(nameof(original)); + } + + return !object.Equals(Configuration, original.Configuration); + } } } diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/DefaultProjectSnapshotManager.cs b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/DefaultProjectSnapshotManager.cs index ac326abd97..78820f1d9a 100644 --- a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/DefaultProjectSnapshotManager.cs +++ b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/DefaultProjectSnapshotManager.cs @@ -10,7 +10,7 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem internal class DefaultProjectSnapshotManager : ProjectSnapshotManagerBase { private readonly ProjectSnapshotChangeTrigger[] _triggers; - private readonly Dictionary _projects; + private readonly Dictionary _projects; private readonly List> _listeners; public DefaultProjectSnapshotManager(IEnumerable triggers, Workspace workspace) @@ -28,7 +28,7 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem _triggers = triggers.ToArray(); Workspace = workspace; - _projects = new Dictionary(); + _projects = new Dictionary(); _listeners = new List>(); for (var i = 0; i < _triggers.Length; i++) @@ -59,6 +59,9 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem var snapshot = new DefaultProjectSnapshot(underlyingProject); _projects[underlyingProject.Id] = snapshot; + // New projects always start dirty, need to compute state in the background. + NotifyBackgroundWorker(); + // We need to notify listeners about every project add. NotifyListeners(new ProjectChangeEventArgs(snapshot, ProjectChangeKind.Added)); } @@ -70,12 +73,49 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem throw new ArgumentNullException(nameof(underlyingProject)); } - // For now we don't have any state associated with the project so we can just construct a new snapshot. - var snapshot = new DefaultProjectSnapshot(underlyingProject); - _projects[underlyingProject.Id] = snapshot; + if (_projects.TryGetValue(underlyingProject.Id, out var original)) + { + // Doing an update to the project should keep computed values, but mark the project as dirty if the + // underlying project is newer. + var snapshot = original.WithProjectChange(underlyingProject); + _projects[underlyingProject.Id] = snapshot; - // There's no need to notify listeners about project changes because we don't have any state. - // This will change when we implement extensibility support. + if (snapshot.IsDirty) + { + // We don't need to notify listeners yet because we don't have any **new** computed state. However we do + // need to trigger the background work to asynchronously compute the effect of the updates. + NotifyBackgroundWorker(); + } + } + } + + public override void ProjectChanged(ProjectSnapshotUpdateContext update) + { + if (update == null) + { + throw new ArgumentNullException(nameof(update)); + } + + if (_projects.TryGetValue(update.UnderlyingProject.Id, out var original)) + { + // This is an update to the project's computed values, so everything should be overwritten + var snapshot = original.WithProjectChange(update); + _projects[update.UnderlyingProject.Id] = snapshot; + + if (snapshot.IsDirty) + { + // It's possible that the snapshot can still be dirty if we got a project update while computing state in + // the background. We need to trigger the background work to asynchronously compute the effect of the updates. + NotifyBackgroundWorker(); + } + + // Now we need to know if the changes that we applied are significant. If that's the case then + // we need to notify listeners. + if (snapshot.HasChangesComparedTo(original)) + { + NotifyListeners(new ProjectChangeEventArgs(snapshot, ProjectChangeKind.Changed)); + } + } } public override void ProjectRemoved(Project underlyingProject) @@ -105,7 +145,14 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem } } - private void NotifyListeners(ProjectChangeEventArgs e) + // virtual so it can be overridden in tests + protected virtual void NotifyBackgroundWorker() + { + + } + + // virtual so it can be overridden in tests + protected virtual void NotifyListeners(ProjectChangeEventArgs e) { for (var i = 0; i < _listeners.Count; i++) { diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/MvcExtensibilityConfiguration.cs b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/MvcExtensibilityConfiguration.cs index febbf2a738..9cf1d19973 100644 --- a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/MvcExtensibilityConfiguration.cs +++ b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/MvcExtensibilityConfiguration.cs @@ -3,6 +3,8 @@ using System; using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Internal; namespace Microsoft.CodeAnalysis.Razor.ProjectSystem { @@ -37,5 +39,27 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem public override ProjectExtensibilityAssembly RazorAssembly { get; } public ProjectExtensibilityAssembly MvcAssembly { get; } + + public override bool Equals(ProjectExtensibilityConfiguration other) + { + if (other == null) + { + return false; + } + + // We're intentionally ignoring the 'Kind' here. That's mostly for diagnostics and doesn't influence any behavior. + return Enumerable.SequenceEqual(Assemblies.OrderBy(a => a.Identity.Name), other.Assemblies.OrderBy(a => a.Identity.Name)); + } + + public override int GetHashCode() + { + var hash = new HashCodeCombiner(); + foreach (var assembly in Assemblies.OrderBy(a => a.Identity.Name)) + { + hash.Add(assembly); + } + + return hash; + } } } diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/ProjectExtensibilityAssembly.cs b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/ProjectExtensibilityAssembly.cs index 64b7a78706..43839f3664 100644 --- a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/ProjectExtensibilityAssembly.cs +++ b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/ProjectExtensibilityAssembly.cs @@ -5,7 +5,7 @@ using System; namespace Microsoft.CodeAnalysis.Razor.ProjectSystem { - internal sealed class ProjectExtensibilityAssembly + internal sealed class ProjectExtensibilityAssembly : IEquatable { public ProjectExtensibilityAssembly(AssemblyIdentity identity) { @@ -18,5 +18,25 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem } public AssemblyIdentity Identity { get; } + + public bool Equals(ProjectExtensibilityAssembly other) + { + if (other == null) + { + return false; + } + + return Identity.Equals(other.Identity); + } + + public override int GetHashCode() + { + return Identity.GetHashCode(); + } + + public override bool Equals(object obj) + { + return base.Equals(obj as ProjectExtensibilityAssembly); + } } } diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/ProjectExtensibilityConfiguration.cs b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/ProjectExtensibilityConfiguration.cs index 15cf8d5429..f230160380 100644 --- a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/ProjectExtensibilityConfiguration.cs +++ b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/ProjectExtensibilityConfiguration.cs @@ -1,16 +1,26 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System; using System.Collections.Generic; namespace Microsoft.CodeAnalysis.Razor.ProjectSystem { - internal abstract class ProjectExtensibilityConfiguration + internal abstract class ProjectExtensibilityConfiguration : IEquatable { public abstract IReadOnlyList Assemblies { get; } public abstract ProjectExtensibilityConfigurationKind Kind { get; } public abstract ProjectExtensibilityAssembly RazorAssembly { get; } + + public abstract bool Equals(ProjectExtensibilityConfiguration other); + + public abstract override int GetHashCode(); + + public override bool Equals(object obj) + { + return base.Equals(obj as ProjectExtensibilityConfiguration); + } } } diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/ProjectSnapshot.cs b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/ProjectSnapshot.cs index 84792c2b47..0a54f9b4f4 100644 --- a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/ProjectSnapshot.cs +++ b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/ProjectSnapshot.cs @@ -1,10 +1,14 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System; + namespace Microsoft.CodeAnalysis.Razor.ProjectSystem { internal abstract class ProjectSnapshot { + public abstract ProjectExtensibilityConfiguration Configuration { get; } + public abstract Project UnderlyingProject { get; } } } diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/ProjectSnapshotManagerBase.cs b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/ProjectSnapshotManagerBase.cs index 151e2dd936..c738ce9182 100644 --- a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/ProjectSnapshotManagerBase.cs +++ b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/ProjectSnapshotManagerBase.cs @@ -11,6 +11,8 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem public abstract void ProjectChanged(Project underlyingProject); + public abstract void ProjectChanged(ProjectSnapshotUpdateContext update); + public abstract void ProjectRemoved(Project underlyingProject); public abstract void ProjectsCleared(); diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/ProjectSnapshotUpdateContext.cs b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/ProjectSnapshotUpdateContext.cs new file mode 100644 index 0000000000..bce7e39b3a --- /dev/null +++ b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ProjectSystem/ProjectSnapshotUpdateContext.cs @@ -0,0 +1,24 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.CodeAnalysis.Razor.ProjectSystem +{ + internal class ProjectSnapshotUpdateContext + { + public ProjectSnapshotUpdateContext(Project underlyingProject) + { + if (underlyingProject == null) + { + throw new ArgumentNullException(nameof(underlyingProject)); + } + + UnderlyingProject = underlyingProject; + } + + public Project UnderlyingProject { get; } + + public ProjectExtensibilityConfiguration Configuration { get; set; } + } +} diff --git a/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/ProjectSystem/DefaultProjectSnapshotManagerTest.cs b/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/ProjectSystem/DefaultProjectSnapshotManagerTest.cs new file mode 100644 index 0000000000..61a69c92a2 --- /dev/null +++ b/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/ProjectSystem/DefaultProjectSnapshotManagerTest.cs @@ -0,0 +1,299 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Linq; +using Moq; +using Xunit; + +namespace Microsoft.CodeAnalysis.Razor.ProjectSystem +{ + public class DefaultProjectSnapshotManagerTest + { + public DefaultProjectSnapshotManagerTest() + { + Workspace = new AdhocWorkspace(); + ProjectManager = new TestProjectSnapshotManager(Enumerable.Empty(), Workspace); + } + + private TestProjectSnapshotManager ProjectManager { get; } + + private Workspace Workspace { get; } + + [Fact] + public void ProjectAdded_AddsProject_NotifiesListeners_AndStartsBackgroundWorker() + { + // Arrange + var project = Workspace.CurrentSolution.AddProject("Test", "Test", LanguageNames.CSharp); + + // Act + ProjectManager.ProjectAdded(project); + + // Assert + var snapshot = ProjectManager.GetSnapshot(project.Id); + Assert.True(snapshot.IsDirty); + + Assert.True(ProjectManager.ListenersNotified); + Assert.True(ProjectManager.WorkerStarted); + } + + [Fact] + public void ProjectChanged_MadeDirty_RetainsComputedState_NotifiesListeners_AndStartsBackgroundWorker() + { + // Arrange + var project = Workspace.CurrentSolution.AddProject("Test", "Test", LanguageNames.CSharp); + ProjectManager.ProjectAdded(project); + ProjectManager.Reset(); + + // Adding some computed state + var configuration = Mock.Of(); + ProjectManager.ProjectChanged(new ProjectSnapshotUpdateContext(project) { Configuration = configuration }); + ProjectManager.Reset(); + + project = project.WithAssemblyName("Test1"); // Simulate a project change + + // Act + ProjectManager.ProjectChanged(project); + + // Assert + var snapshot = ProjectManager.GetSnapshot(project.Id); + Assert.True(snapshot.IsDirty); + Assert.Same(configuration, snapshot.Configuration); + + Assert.False(ProjectManager.ListenersNotified); + Assert.True(ProjectManager.WorkerStarted); + } + + [Fact] + public void ProjectChanged_BackgroundUpdate_MadeClean_WithSignificantChanges_NotifiesListeners_AndDoesNotStartBackgroundWorker() + { + // Arrange + var project = Workspace.CurrentSolution.AddProject("Test", "Test", LanguageNames.CSharp); + ProjectManager.ProjectAdded(project); + ProjectManager.Reset(); + + var configuration = Mock.Of(); + + // Act + ProjectManager.ProjectChanged(new ProjectSnapshotUpdateContext(project) { Configuration = configuration }); + + // Assert + var snapshot = ProjectManager.GetSnapshot(project.Id); + Assert.False(snapshot.IsDirty); + Assert.Same(configuration, snapshot.Configuration); + + Assert.True(ProjectManager.ListenersNotified); + Assert.False(ProjectManager.WorkerStarted); + } + + [Fact] + public void ProjectChanged_BackgroundUpdate_MadeClean_WithoutSignificantChanges_NotifiesListeners_AndDoesNotStartBackgroundWorker() + { + // Arrange + var project = Workspace.CurrentSolution.AddProject("Test", "Test", LanguageNames.CSharp); + ProjectManager.ProjectAdded(project); + ProjectManager.Reset(); + + var configuration = Mock.Of(); + ProjectManager.ProjectChanged(new ProjectSnapshotUpdateContext(project) { Configuration = configuration }); + ProjectManager.Reset(); + + project = project.WithAssemblyName("Test1"); // Simulate a project change + ProjectManager.ProjectChanged(project); + ProjectManager.Reset(); + + // Act + ProjectManager.ProjectChanged(new ProjectSnapshotUpdateContext(project) { Configuration = configuration }); + + // Assert + var snapshot = ProjectManager.GetSnapshot(project.Id); + Assert.False(snapshot.IsDirty); + Assert.Same(configuration, snapshot.Configuration); + + Assert.False(ProjectManager.ListenersNotified); + Assert.False(ProjectManager.WorkerStarted); + } + + [Fact] + public void ProjectChanged_BackgroundUpdate_StillDirty_WithSignificantChanges_NotifiesListeners_AndStartsBackgroundWorker() + { + // Arrange + var project = Workspace.CurrentSolution.AddProject("Test", "Test", LanguageNames.CSharp); + ProjectManager.ProjectAdded(project); + ProjectManager.Reset(); + + var configuration = Mock.Of(); + + // Compute an update for "Test" + var update = new ProjectSnapshotUpdateContext(project) { Configuration = configuration }; + + project = project.WithAssemblyName("Test1"); // Simulate a project change + ProjectManager.ProjectChanged(project); + ProjectManager.Reset(); + + // Act + ProjectManager.ProjectChanged(update); + + // Assert + var snapshot = ProjectManager.GetSnapshot(project.Id); + Assert.True(snapshot.IsDirty); + Assert.Same(configuration, snapshot.Configuration); + + Assert.True(ProjectManager.ListenersNotified); + Assert.True(ProjectManager.WorkerStarted); + } + + [Fact] + public void ProjectChanged_BackgroundUpdate_StillDirty_WithoutSignificantChanges_NotifiesListeners_AndStartsBackgroundWorker() + { + // Arrange + var project = Workspace.CurrentSolution.AddProject("Test", "Test", LanguageNames.CSharp); + ProjectManager.ProjectAdded(project); + ProjectManager.Reset(); + + var configuration = Mock.Of(); + ProjectManager.ProjectChanged(new ProjectSnapshotUpdateContext(project) { Configuration = configuration }); + + project = project.WithAssemblyName("Test1"); // Simulate a project change + ProjectManager.ProjectChanged(project); + ProjectManager.Reset(); + + // Compute an update for "Test1" + var update = new ProjectSnapshotUpdateContext(project) { Configuration = configuration }; + + project = project.WithAssemblyName("Test2"); // Simulate a project change + ProjectManager.ProjectChanged(project); + ProjectManager.Reset(); + + // Act + ProjectManager.ProjectChanged(update); // Still dirty because the project changed while computing the update + + // Assert + var snapshot = ProjectManager.GetSnapshot(project.Id); + Assert.True(snapshot.IsDirty); + Assert.Same(configuration, snapshot.Configuration); + + Assert.False(ProjectManager.ListenersNotified); + Assert.True(ProjectManager.WorkerStarted); + } + + [Fact] + public void ProjectChanged_IgnoresUnknownProject() + { + // Arrange + var project = Workspace.CurrentSolution.AddProject("Test", "Test", LanguageNames.CSharp); + + // Act + ProjectManager.ProjectChanged(project); + + // Assert + Assert.Empty(ProjectManager.Projects); + + Assert.False(ProjectManager.ListenersNotified); + Assert.False(ProjectManager.WorkerStarted); + } + + [Fact] + public void ProjectChanged_WithComputedState_IgnoresUnknownProject() + { + // Arrange + var project = Workspace.CurrentSolution.AddProject("Test", "Test", LanguageNames.CSharp); + + // Act + ProjectManager.ProjectChanged(new ProjectSnapshotUpdateContext(project)); + + // Assert + Assert.Empty(ProjectManager.Projects); + + Assert.False(ProjectManager.ListenersNotified); + Assert.False(ProjectManager.WorkerStarted); + } + + [Fact] + public void ProjectRemoved_RemovesProject_NotifiesListeners_DoesNotStartBackgroundWorker() + { + // Arrange + var project = Workspace.CurrentSolution.AddProject("Test", "Test", LanguageNames.CSharp); + + ProjectManager.ProjectAdded(project); + ProjectManager.Reset(); + + // Act + ProjectManager.ProjectRemoved(project); + + // Assert + Assert.Empty(ProjectManager.Projects); + + Assert.True(ProjectManager.ListenersNotified); + Assert.False(ProjectManager.WorkerStarted); + } + + [Fact] + public void ProjectRemoved_IgnoresUnknownProject() + { + // Arrange + var project = Workspace.CurrentSolution.AddProject("Test", "Test", LanguageNames.CSharp); + + // Act + ProjectManager.ProjectRemoved(project); + + // Assert + Assert.Empty(ProjectManager.Projects); + + Assert.False(ProjectManager.ListenersNotified); + Assert.False(ProjectManager.WorkerStarted); + } + + [Fact] + public void ProjectsCleared_RemovesProject_NotifiesListeners_DoesNotStartBackgroundWorker() + { + // Arrange + var project = Workspace.CurrentSolution.AddProject("Test", "Test", LanguageNames.CSharp); + + ProjectManager.ProjectAdded(project); + ProjectManager.Reset(); + + // Act + ProjectManager.ProjectsCleared(); + + // Assert + Assert.Empty(ProjectManager.Projects); + + Assert.True(ProjectManager.ListenersNotified); + Assert.False(ProjectManager.WorkerStarted); + } + + private class TestProjectSnapshotManager : DefaultProjectSnapshotManager + { + public TestProjectSnapshotManager(IEnumerable triggers, Workspace workspace) + : base(triggers, workspace) + { + } + + public bool ListenersNotified { get; private set; } + + public bool WorkerStarted { get; private set; } + + public DefaultProjectSnapshot GetSnapshot(ProjectId id) + { + return Projects.Cast().FirstOrDefault(s => s.UnderlyingProject.Id == id); + } + + public void Reset() + { + ListenersNotified = false; + WorkerStarted = false; + } + + protected override void NotifyListeners(ProjectChangeEventArgs e) + { + ListenersNotified = true; + } + + protected override void NotifyBackgroundWorker() + { + WorkerStarted = true; + } + } + } +}