diff --git a/src/GitHub.App/Caches/AutoCompleteSourceCache.cs b/src/GitHub.App/Caches/AutoCompleteSourceCache.cs deleted file mode 100644 index 543ceef1b7..0000000000 --- a/src/GitHub.App/Caches/AutoCompleteSourceCache.cs +++ /dev/null @@ -1,147 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Globalization; -using System.Reactive.Linq; -using System.Reactive.Subjects; -using GitHub.Api; -using GitHub.Extensions; -using GitHub.Helpers; -using GitHub.Models; -using GitHub.Services; -using ReactiveUI; - -namespace GitHub.Cache -{ - public abstract class AutoCompleteSourceCache : IAutoCompleteSourceCache - { - static readonly NLog.Logger log = NLog.LogManager.GetCurrentClassLogger(); - - readonly SerializedObservableProvider> serializedSuggestions; - readonly TimeSpan cacheDuration; - readonly TimeSpan maxCacheDuration; - - protected AutoCompleteSourceCache(TimeSpan cacheDuration, TimeSpan maxCacheDuration) - { - this.cacheDuration = cacheDuration; - this.maxCacheDuration = maxCacheDuration; - - serializedSuggestions = new SerializedObservableProvider>( - GetAndFetchCachedSourceItemsImpl); - } - - // We append this to the cache key to differentiate the various auto completion caches. - protected abstract string CacheSuffix { get; } - - /// - /// Retrieves suggestions from the cache for the specified repository. If not there, it makes an API - /// call to retrieve them. - /// - /// The repository that contains the suggestion items. - /// An observable containing a readonly list of auto complete suggestions - public IObservable> RetrieveSuggestions(IRepositoryModel repository) - { - Guard.ArgumentNotNull(repository, "repository"); - - return serializedSuggestions.Get(repository); - } - - /// - /// Calls the API to fetch mentionables, issues, or whatever. - /// - /// Existing items in the cache. Useful for incremental cache updates - /// The repository containing the items to fetch - /// The API client to use to make the request - protected abstract IObservable FetchSuggestionsSourceItems( - CachedData> existingCachedItems, - IRepositoryModel repository, - IApiClient apiClient); - - IObservable> GetAndFetchExistingCachedSourceItems( - IHostCache hostCache, - IRepositoryModel repository, - Func>, IObservable>> fetchFunc) - { - Debug.Assert(repository != null, "Repository cannot be null because we validated it at the callsite"); - Debug.Assert(hostCache != null, "HostCache cannot be null because we validated it at the callsite"); - - return GetOrFetchCachedItems(hostCache, repository, fetchFunc); - } - - IObservable> GetAndFetchCachedSourceItemsImpl(IRepositoryModel repository) - { - Debug.Assert(repository != null, "Repository cannot be null because we validated it at the callsite"); - - return Observable.Defer(() => - { - var hostCache = repository.RepositoryHost != null - ? repository.RepositoryHost.Cache - : null; - - if (hostCache == null) - { - return Observable.Empty>(); - } - - var ret = new ReplaySubject>(1); - - GetAndFetchExistingCachedSourceItems(hostCache, repository, GetCacheableSuggestions) - .Catch, Exception>(_ => Observable.Return(new List())) - .Multicast(ret) - .PermaRef(); - - // If GetAndFetchExistingCachedSourceItems finds that the cache item is stale it produces the - // stale value and then fetches and produces a fresh one. It can thus produce - // 1, 2 or no values (if cache miss and fetch fails). While I'd ideally want - // to expose that through this method so that the suggestions list in the UI get updated - // as soon as we have a fresh value this method has historically only produced - // one value so in an effort to reduce scope I'm keeping it that way. We - // unfortunately still need to maintain the subscription to GetAndRefresh though - // so that we don't cancel the refresh as soon as we get the stale object. - return ret.Take(1); - }); - } - - IObservable> GetCacheableSuggestions( - IRepositoryModel repository, - CachedData> existingCachedItems) - { - Debug.Assert(repository != null, "Repository cannot be null because we validated it at the callsite"); - - var apiClient = repository.RepositoryHost != null ? repository.RepositoryHost.ApiClient : null; - if (apiClient == null) - { - return Observable.Empty>(); - } - - // Our current serializer can't handle deserializing IReadOnlyList. That's why we need a concrete list - // here. - return FetchSuggestionsSourceItems(existingCachedItems, repository, apiClient) - .ToConcreteList(); - } - - IObservable> GetOrFetchCachedItems( - IHostCache hostCache, - IRepositoryModel repositoryModel, - Func>, IObservable>> fetchFunc) - where T : class - { - Ensure.ArgumentNotNull(repositoryModel, "repositoryModel"); - Ensure.ArgumentNotNull(fetchFunc, "fetchFunc"); - - string cacheKey = repositoryModel.NameWithOwner + ":" + CacheSuffix; - return hostCache.LocalMachine.GetCachedValueThenFetchForNextTime>( - cacheKey, - cacheData => fetchFunc(repositoryModel, cacheData), - cacheDuration, - maxCacheDuration) - .Catch, Exception>(ex => - { - log.Info(String.Format(CultureInfo.InvariantCulture, - "Exception occurred attempting to get a cached value and then fetch '{0}'", cacheKey), ex); - return Observable.Return(new List()); - }) - .Select(result => result ?? new List()); - } - } -} diff --git a/src/GitHub.App/Caches/IAutoCompleteSourceCache.cs b/src/GitHub.App/Caches/IAutoCompleteSourceCache.cs deleted file mode 100644 index ad50aa00ef..0000000000 --- a/src/GitHub.App/Caches/IAutoCompleteSourceCache.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System; -using System.Collections.Generic; -using GitHub.Models; - -namespace GitHub.Cache -{ - public interface IAutoCompleteSourceCache - { - /// - /// Retrieves suggestions from the cache for the specified repository. If not there, it makes an API - /// call to retrieve them. - /// - /// The repository that contains the users - /// An observable containing a readonly list of issue suggestions - IObservable> RetrieveSuggestions(IRepositoryModel repositoryModel); - } -} diff --git a/src/GitHub.App/Caches/IIssuesCache.cs b/src/GitHub.App/Caches/IIssuesCache.cs deleted file mode 100644 index d99bcc64e9..0000000000 --- a/src/GitHub.App/Caches/IIssuesCache.cs +++ /dev/null @@ -1,12 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -namespace GitHub.Cache -{ - /// - /// Used to cache and supply #issues in the autocomplete control. - /// - [SuppressMessage("Microsoft.Design", "CA1040:AvoidEmptyInterfaces", - Justification = "Yeah, it's empty, but it makes it easy to import the correct one.")] - public interface IIssuesCache : IAutoCompleteSourceCache - { - } -} \ No newline at end of file diff --git a/src/GitHub.App/Caches/IMentionsCache.cs b/src/GitHub.App/Caches/IMentionsCache.cs deleted file mode 100644 index b0ef8a0391..0000000000 --- a/src/GitHub.App/Caches/IMentionsCache.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Diagnostics.CodeAnalysis; -using GitHub.Cache; - -namespace GitHub.Helpers -{ - /// - /// Used to cache and supply @mentions in the autocomplete control. - /// - [SuppressMessage("Microsoft.Design", "CA1040:AvoidEmptyInterfaces", - Justification = "Yeah, it's empty, but it makes it easy to import the correct one.")] - public interface IMentionsCache : IAutoCompleteSourceCache - { - } -} \ No newline at end of file diff --git a/src/GitHub.App/Caches/IssuesCache.cs b/src/GitHub.App/Caches/IssuesCache.cs deleted file mode 100644 index 96dd3407e9..0000000000 --- a/src/GitHub.App/Caches/IssuesCache.cs +++ /dev/null @@ -1,80 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel.Composition; -using System.Linq; -using System.Reactive.Linq; -using GitHub.Api; -using GitHub.Extensions; -using GitHub.Models; -using GitHub.ViewModels; -using Octokit; - -namespace GitHub.Cache -{ - [Export(typeof(IIssuesCache))] - [Export(typeof(IAutoCompleteSourceCache))] - [PartCreationPolicy(CreationPolicy.Shared)] - public class IssuesCache : AutoCompleteSourceCache, IIssuesCache - { - // Just needs to be some value before GitHub stored its first issue. - static readonly DateTimeOffset lowerBound = new DateTimeOffset(2000, 1, 1, 12, 0, 0, TimeSpan.FromSeconds(0)); - - [ImportingConstructor] - public IssuesCache() : base(TimeSpan.FromSeconds(10), TimeSpan.FromDays(7)) - { - } - - protected override string CacheSuffix - { - get { return "issues"; } - } - - protected override IObservable FetchSuggestionsSourceItems( - CachedData> existingCachedItems, - IRepositoryModel repository, - IApiClient apiClient) - { - var data = (existingCachedItems.Data ?? new List()) - .Where(item => !String.IsNullOrEmpty(item.Name)) // Helps handle cache corruption - .ToList(); - - if (data.IsEmpty()) - { - return apiClient.GetIssuesForRepository(repository.Owner, repository.Name) - .Select(ConvertToSuggestionItem); - } - - // Update cache with changes - var since = data.Max(issue => issue.LastModifiedDate ?? lowerBound).ToUniversalTime(); - var existingIssues = data.ToDictionary(i => i.Name, i => i); - return apiClient.GetIssuesChangedSince(repository.Owner, repository.Name, since) - .WhereNotNull() - .Do(issue => - { - var suggestionItem = ConvertToSuggestionItem(issue); - // Remove closed ones. - if (issue.State == ItemState.Closed) - { - existingIssues.Remove(suggestionItem.Name); - } - else - { - // Adds new ones (this is basically a noop for existing ones) - existingIssues[suggestionItem.Name] = suggestionItem; - } - }) - .ToList() // We always want to return existing issues. - .SelectMany(_ => existingIssues.Values.ToObservable()); - } - - static SuggestionItem ConvertToSuggestionItem(Issue issue) - { - return new SuggestionItem("#" + issue.Number, issue.Title) - { - // Just in case CreatedAt isn't set, we'll use UTCNow. - LastModifiedDate = issue.UpdatedAt - ?? (issue.CreatedAt == DateTimeOffset.MinValue ? DateTimeOffset.UtcNow : issue.CreatedAt) - }; - } - } -} diff --git a/src/GitHub.App/Caches/MentionsCache.cs b/src/GitHub.App/Caches/MentionsCache.cs deleted file mode 100644 index 1c2872bae6..0000000000 --- a/src/GitHub.App/Caches/MentionsCache.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System; -using System.Collections.Generic; -using System.ComponentModel.Composition; -using System.Reactive.Linq; -using GitHub.Api; -using GitHub.Helpers; -using GitHub.Models; -using Octokit; - -namespace GitHub.Cache -{ - /// - /// Used to cache and supply @mentions in the autocomplete control. - /// - [Export(typeof(IMentionsCache))] - [Export(typeof(IAutoCompleteSourceCache))] - [PartCreationPolicy(CreationPolicy.Shared)] - public class MentionsCache : AutoCompleteSourceCache, IMentionsCache - { - public MentionsCache() : base(TimeSpan.FromHours(12), TimeSpan.FromDays(7)) - { - } - - protected override string CacheSuffix - { - get { return "mentions"; } - } - - protected override IObservable FetchSuggestionsSourceItems( - CachedData> existingCachedItems, - IRepositoryModel repository, - IApiClient apiClient) - { - return apiClient.GetMentionables(repository.Owner, repository.Name) - .Select(ConvertToSuggestionItem); - } - - static SuggestionItem ConvertToSuggestionItem(AccountMention sourceItem) - { - return new SuggestionItem(sourceItem.Login, sourceItem.Name ?? "(unknown)", GetUrlSafe(sourceItem.AvatarUrl)); - } - - static Uri GetUrlSafe(string url) - { - Uri uri; - Uri.TryCreate(url, UriKind.Absolute, out uri); - return uri; - } - } -} diff --git a/src/GitHub.App/Models/SuggestionItem.cs b/src/GitHub.App/Models/SuggestionItem.cs index 0b8bd59644..713e0357e9 100644 --- a/src/GitHub.App/Models/SuggestionItem.cs +++ b/src/GitHub.App/Models/SuggestionItem.cs @@ -1,4 +1,5 @@ using System; +using GitHub.Extensions; using GitHub.Helpers; namespace GitHub.Models @@ -9,36 +10,22 @@ namespace GitHub.Models /// public class SuggestionItem { - public SuggestionItem() // So this can be deserialized from cache - { - } - - public SuggestionItem(string name, Uri iconCacheKey) - { - Ensure.ArgumentNotNullOrEmptyString(name, "name"); - Ensure.ArgumentNotNull(iconCacheKey, "iconCacheKey"); - - Name = name; - IconKey = iconCacheKey; - } - public SuggestionItem(string name, string description) { - Ensure.ArgumentNotNullOrEmptyString(name, "name"); - Ensure.ArgumentNotNullOrEmptyString(description, "description"); + Guard.ArgumentNotEmptyString(name, "name"); + Guard.ArgumentNotEmptyString(description, "description"); Name = name; Description = description; } - public SuggestionItem(string name, string description, Uri iconCacheKey) + public SuggestionItem(string name, string description, string imageUrl) { - Ensure.ArgumentNotNullOrEmptyString(name, "name"); - Ensure.ArgumentNotNull(iconCacheKey, "iconCacheKey"); + Guard.ArgumentNotEmptyString(name, "name"); Name = name; Description = description; - IconKey = iconCacheKey; + ImageUrl = imageUrl; } /// @@ -52,9 +39,9 @@ public SuggestionItem(string name, string description, Uri iconCacheKey) public string Description { get; set; } /// - /// A key to lookup when displaying the icon for this entry + /// An image url for this entry /// - public Uri IconKey { get; set; } + public string ImageUrl { get; set; } /// /// The date this suggestion was last modified according to the API. diff --git a/src/GitHub.App/SampleData/CommentViewModelDesigner.cs b/src/GitHub.App/SampleData/CommentViewModelDesigner.cs index b060088de9..132fe03568 100644 --- a/src/GitHub.App/SampleData/CommentViewModelDesigner.cs +++ b/src/GitHub.App/SampleData/CommentViewModelDesigner.cs @@ -3,6 +3,7 @@ using System.Reactive; using System.Threading.Tasks; using GitHub.Models; +using GitHub.Services; using GitHub.ViewModels; using ReactiveUI; @@ -37,6 +38,7 @@ public CommentViewModelDesigner() public ReactiveCommand CommitEdit { get; } public ReactiveCommand OpenOnGitHub { get; } = ReactiveCommand.Create(() => { }); public ReactiveCommand Delete { get; } + public IAutoCompleteAdvisor AutoCompleteAdvisor { get; } public Task InitializeAsync(ICommentThreadViewModel thread, ActorModel currentUser, CommentModel comment, CommentEditState state) { diff --git a/src/GitHub.App/SampleData/PullRequestCreationViewModelDesigner.cs b/src/GitHub.App/SampleData/PullRequestCreationViewModelDesigner.cs index 32922438d8..77edc7ab89 100644 --- a/src/GitHub.App/SampleData/PullRequestCreationViewModelDesigner.cs +++ b/src/GitHub.App/SampleData/PullRequestCreationViewModelDesigner.cs @@ -4,6 +4,7 @@ using System.Reactive; using System.Threading.Tasks; using GitHub.Models; +using GitHub.Services; using GitHub.Validation; using GitHub.ViewModels.GitHubPane; using ReactiveUI; @@ -53,6 +54,7 @@ public PullRequestCreationViewModelDesigner() public string PRTitle { get; set; } public ReactivePropertyValidator TitleValidator { get; } + public IAutoCompleteAdvisor AutoCompleteAdvisor { get; } public ReactivePropertyValidator BranchValidator { get; } diff --git a/src/GitHub.App/SampleData/PullRequestReviewAuthoringViewModelDesigner.cs b/src/GitHub.App/SampleData/PullRequestReviewAuthoringViewModelDesigner.cs index cfc8eb23e4..9d0c118cbb 100644 --- a/src/GitHub.App/SampleData/PullRequestReviewAuthoringViewModelDesigner.cs +++ b/src/GitHub.App/SampleData/PullRequestReviewAuthoringViewModelDesigner.cs @@ -3,6 +3,7 @@ using System.Reactive; using System.Threading.Tasks; using GitHub.Models; +using GitHub.Services; using GitHub.ViewModels.GitHubPane; using ReactiveUI; @@ -53,6 +54,7 @@ public PullRequestReviewAuthoringViewModelDesigner() public ReactiveCommand Comment { get; } public ReactiveCommand RequestChanges { get; } public ReactiveCommand Cancel { get; } + public IAutoCompleteAdvisor AutoCompleteAdvisor { get; } public Task InitializeAsync( LocalRepositoryModel localRepository, diff --git a/src/GitHub.App/Services/AutoCompleteAdvisor.cs b/src/GitHub.App/Services/AutoCompleteAdvisor.cs index c1d610526c..569a87b526 100644 --- a/src/GitHub.App/Services/AutoCompleteAdvisor.cs +++ b/src/GitHub.App/Services/AutoCompleteAdvisor.cs @@ -7,8 +7,10 @@ using System.Globalization; using System.Linq; using System.Reactive.Linq; -using GitHub.UI; -using NLog; +using GitHub.Extensions; +using GitHub.Logging; +using GitHub.Models; +using Serilog; namespace GitHub.Services { @@ -18,11 +20,11 @@ public class AutoCompleteAdvisor : IAutoCompleteAdvisor { const int SuggestionCount = 5; // The number of suggestions we'll provide. github.com does 5. - static readonly Logger log = LogManager.GetCurrentClassLogger(); + static readonly ILogger log = LogManager.ForContext(); readonly Lazy> prefixSourceMap; [ImportingConstructor] - public AutoCompleteAdvisor([ImportMany]IEnumerable autocompleteSources) + public AutoCompleteAdvisor([ImportMany(typeof(IAutoCompleteSource))]IEnumerable autocompleteSources) { prefixSourceMap = new Lazy>( () => autocompleteSources.ToDictionary(s => s.Prefix, s => s)); @@ -30,7 +32,7 @@ public AutoCompleteAdvisor([ImportMany]IEnumerable autocomp public IObservable GetAutoCompletionSuggestions(string text, int caretPosition) { - Ensure.ArgumentNotNull("text", text); + Guard.ArgumentNotNull("text", text); if (caretPosition < 0 || caretPosition > text.Length) { @@ -73,7 +75,7 @@ public IObservable GetAutoCompletionSuggestions(string text, new ReadOnlyCollection(suggestions))) .Catch(e => { - log.Info(e); + log.Error(e, "Error Getting AutoCompleteResult"); return Observable.Return(AutoCompleteResult.Empty); }); } @@ -82,8 +84,8 @@ public IObservable GetAutoCompletionSuggestions(string text, , Justification = "We ensure the argument is greater than -1 so it can't overflow")] public static AutoCompletionToken ParseAutoCompletionToken(string text, int caretPosition, string triggerPrefix) { - Ensure.ArgumentNotNull("text", text); - Ensure.ArgumentInRange(caretPosition, 0, text.Length, "caretPosition"); + Guard.ArgumentNotNull("text", text); + Guard.ArgumentInRange(caretPosition, 0, text.Length, "caretPosition"); if (caretPosition == 0 || text.Length == 0) return null; // :th : 1 @@ -103,8 +105,8 @@ public class AutoCompletionToken { public AutoCompletionToken(string searchPrefix, int offset) { - Ensure.ArgumentNotNull(searchPrefix, "searchPrefix"); - Ensure.ArgumentNonNegative(offset, "offset"); + Guard.ArgumentNotNull(searchPrefix, "searchPrefix"); + Guard.ArgumentNonNegative(offset, "offset"); SearchSearchPrefix = searchPrefix; Offset = offset; diff --git a/src/GitHub.App/Services/EmojiAutoCompleteSource.cs b/src/GitHub.App/Services/EmojiAutoCompleteSource.cs deleted file mode 100644 index 73c9f2780c..0000000000 --- a/src/GitHub.App/Services/EmojiAutoCompleteSource.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System; -using System.ComponentModel.Composition; -using System.Linq; -using System.Reactive.Linq; -using System.Windows.Media.Imaging; -using GitHub.UI; - -namespace GitHub.Services -{ - [Export(typeof(IAutoCompleteSource))] - [PartCreationPolicy(CreationPolicy.Shared)] - public class EmojiAutoCompleteSource : IAutoCompleteSource - { - readonly IEmojiCache emojiCache; - - [ImportingConstructor] - public EmojiAutoCompleteSource(IEmojiCache emojiCache) - { - Ensure.ArgumentNotNull(emojiCache, "emojiCache"); - - this.emojiCache = emojiCache; - } - - public IObservable GetSuggestions() - { - Func> resolveImage = uri => - Observable.Defer(() => - { - var resourcePath = "pack://application:,,,/GitHub;component/" + uri; - return Observable.Return(App.CreateBitmapImage(resourcePath)); - }); - - return emojiCache.GetEmojis() - .Where(emoji => !String.IsNullOrEmpty(emoji.Name)) // Just being extra cautious. - .Select(emoji => new AutoCompleteSuggestion(emoji.Name, resolveImage(emoji.IconKey), ":", ":")) - .ToObservable(); - } - - public string Prefix { get { return ":"; } } - } -} diff --git a/src/GitHub.App/Services/IAutoCompleteSource.cs b/src/GitHub.App/Services/IAutoCompleteSource.cs index 0aea7bf1a7..09b77c4cc3 100644 --- a/src/GitHub.App/Services/IAutoCompleteSource.cs +++ b/src/GitHub.App/Services/IAutoCompleteSource.cs @@ -1,6 +1,5 @@ using System; using GitHub.Models; -using GitHub.UI; namespace GitHub.Services { diff --git a/src/GitHub.App/Services/IssuesAutoCompleteSource.cs b/src/GitHub.App/Services/IssuesAutoCompleteSource.cs index 1ceca2a1d2..d2c64e671c 100644 --- a/src/GitHub.App/Services/IssuesAutoCompleteSource.cs +++ b/src/GitHub.App/Services/IssuesAutoCompleteSource.cs @@ -3,10 +3,13 @@ using System.ComponentModel.Composition; using System.Linq; using System.Reactive.Linq; -using GitHub.Cache; +using GitHub.Api; +using GitHub.Extensions; using GitHub.Models; -using GitHub.UI; -using GitHub.ViewModels; +using GitHub.Primitives; +using Octokit.GraphQL; +using Octokit.GraphQL.Model; +using static Octokit.GraphQL.Variable; namespace GitHub.Services { @@ -14,33 +17,80 @@ namespace GitHub.Services [PartCreationPolicy(CreationPolicy.Shared)] public class IssuesAutoCompleteSource : IAutoCompleteSource { - readonly Lazy issuesCache; - readonly Lazy currentRepositoryState; + readonly ITeamExplorerContext teamExplorerContext; + readonly IGraphQLClientFactory graphqlFactory; + ICompiledQuery> query; [ImportingConstructor] - public IssuesAutoCompleteSource( - Lazy issuesCache, - Lazy currentRepositoryState) + public IssuesAutoCompleteSource(ITeamExplorerContext teamExplorerContext, IGraphQLClientFactory graphqlFactory) { - Ensure.ArgumentNotNull(issuesCache, "issuesCache"); - Ensure.ArgumentNotNull(currentRepositoryState, "currentRepositoryState"); + Guard.ArgumentNotNull(teamExplorerContext, nameof(teamExplorerContext)); + Guard.ArgumentNotNull(graphqlFactory, nameof(graphqlFactory)); - this.issuesCache = issuesCache; - this.currentRepositoryState = currentRepositoryState; + this.teamExplorerContext = teamExplorerContext; + this.graphqlFactory = graphqlFactory; } public IObservable GetSuggestions() { - if (CurrentRepository.RepositoryHost == null) + var localRepositoryModel = teamExplorerContext.ActiveRepository; + + var hostAddress = HostAddress.Create(localRepositoryModel.CloneUrl.Host); + var owner = localRepositoryModel.Owner; + var name = localRepositoryModel.Name; + + string filter; + string after; + + if (query == null) { - return Observable.Empty(); + query = new Query().Search(query: Var(nameof(filter)), SearchType.Issue, 100, after: Var(nameof(after))) + .Select(item => new Page + { + Items = item.Nodes.Select(searchResultItem => + searchResultItem.Switch(selector => selector + .Issue(i => new SuggestionItem("#" + i.Number, i.Title) { LastModifiedDate = i.LastEditedAt }) + .PullRequest(p => new SuggestionItem("#" + p.Number, p.Title) { LastModifiedDate = p.LastEditedAt })) + ).ToList(), + EndCursor = item.PageInfo.EndCursor, + HasNextPage = item.PageInfo.HasNextPage, + TotalCount = item.IssueCount + }) + .Compile(); } - return IssuesCache.RetrieveSuggestions(CurrentRepository) - .Catch, Exception>(_ => Observable.Empty>()) - .SelectMany(x => x.ToObservable()) - .Where(suggestion => !String.IsNullOrEmpty(suggestion.Name)) // Just being extra cautious - .Select(suggestion => new IssueAutoCompleteSuggestion(suggestion, Prefix)); + filter = $"repo:{owner}/{name}"; + + return Observable.FromAsync(async () => + { + var results = new List(); + + var variables = new Dictionary + { + {nameof(filter), filter }, + }; + + var connection = await graphqlFactory.CreateConnection(hostAddress); + var searchResults = await connection.Run(query, variables); + + results.AddRange(searchResults.Items); + + while (searchResults.HasNextPage) + { + variables[nameof(after)] = searchResults.EndCursor; + searchResults = await connection.Run(query, variables); + + results.AddRange(searchResults.Items); + } + + return results.Select(item => new IssueAutoCompleteSuggestion(item, Prefix)); + + }).SelectMany(observable => observable); + } + + class SearchResult + { + public SuggestionItem SuggestionItem { get; set; } } public string Prefix @@ -48,10 +98,6 @@ public string Prefix get { return "#"; } } - IIssuesCache IssuesCache { get { return issuesCache.Value; } } - - IRepositoryModel CurrentRepository { get { return currentRepositoryState.Value.SelectedRepository; } } - class IssueAutoCompleteSuggestion : AutoCompleteSuggestion { // Just needs to be some value before GitHub stored its first issue. diff --git a/src/GitHub.App/Services/MentionsAutoCompleteSource.cs b/src/GitHub.App/Services/MentionsAutoCompleteSource.cs index e90c0b0d9b..68fc002292 100644 --- a/src/GitHub.App/Services/MentionsAutoCompleteSource.cs +++ b/src/GitHub.App/Services/MentionsAutoCompleteSource.cs @@ -1,15 +1,15 @@ using System; using System.Collections.Generic; using System.ComponentModel.Composition; +using System.Linq; using System.Reactive.Linq; using System.Windows.Media.Imaging; -using GitHub.Caches; +using GitHub.Api; using GitHub.Extensions; -using GitHub.Helpers; using GitHub.Models; -using GitHub.Services; -using GitHub.UI; -using GitHub.ViewModels; +using GitHub.Primitives; +using Octokit.GraphQL; +using static Octokit.GraphQL.Variable; namespace GitHub.Services { @@ -20,58 +20,77 @@ namespace GitHub.Services [PartCreationPolicy(CreationPolicy.Shared)] public class MentionsAutoCompleteSource : IAutoCompleteSource { - readonly Lazy mentionsCache; - readonly Lazy currentRepositoryState; - readonly Lazy imageCache; - readonly IAvatarProvider hostAvatarProvider; + const string DefaultAvatar = "pack://application:,,,/GitHub.App;component/Images/default_user_avatar.png"; + + readonly ITeamExplorerContext teamExplorerContext; + readonly IGraphQLClientFactory graphqlFactory; + readonly IAvatarProvider avatarProvider; + ICompiledQuery> query; [ImportingConstructor] public MentionsAutoCompleteSource( - Lazy mentionsCache, - Lazy imageCache, - IAvatarProvider hostAvatarProvider) + ITeamExplorerContext teamExplorerContext, + IGraphQLClientFactory graphqlFactory, + IAvatarProvider avatarProvider) { - Guard.ArgumentNotNull(mentionsCache, "mentionsCache"); - Guard.ArgumentNotNull(currentRepositoryState, "currentRepositoryState"); - Guard.ArgumentNotNull(imageCache, "imageCache"); - Guard.ArgumentNotNull(hostAvatarProvider, "hostAvatarProvider"); + Guard.ArgumentNotNull(teamExplorerContext, nameof(teamExplorerContext)); + Guard.ArgumentNotNull(graphqlFactory, nameof(graphqlFactory)); + Guard.ArgumentNotNull(avatarProvider, nameof(avatarProvider)); - this.mentionsCache = mentionsCache; - this.currentRepositoryState = currentRepositoryState; - this.imageCache = imageCache; - this.hostAvatarProvider = hostAvatarProvider; + this.teamExplorerContext = teamExplorerContext; + this.graphqlFactory = graphqlFactory; + this.avatarProvider = avatarProvider; } public IObservable GetSuggestions() { - if (CurrentRepository.RepositoryHost == null) + var localRepositoryModel = teamExplorerContext.ActiveRepository; + + var hostAddress = HostAddress.Create(localRepositoryModel.CloneUrl.Host); + var owner = localRepositoryModel.Owner; + var name = localRepositoryModel.Name; + + if (query == null) { - return Observable.Empty(); + query = new Query().Repository(owner: Var(nameof(owner)), name: Var(nameof(name))) + .Select(repository => + repository.MentionableUsers(null, null, null, null) + .AllPages() + .Select(sourceItem => + new SuggestionItem(sourceItem.Login, + sourceItem.Name ?? "(unknown)", + sourceItem.AvatarUrl(null))) + .ToList()) + .Compile(); } - var avatarProviderKey = CurrentRepository.RepositoryHost.Address.WebUri.ToString(); - var avatarProvider = hostAvatarProvider.Get(avatarProviderKey); - - Func> resolveImage = uri => - Observable.Defer(() => ImageCache - .GetImage(uri) - .Catch(_ => Observable.Return(avatarProvider.DefaultUserBitmapImage)) - .StartWith(avatarProvider.DefaultUserBitmapImage)); + var variables = new Dictionary + { + {nameof(owner), owner }, + {nameof(name), name }, + }; - return MentionsCache.RetrieveSuggestions(CurrentRepository) - .Catch, Exception>(_ => Observable.Empty>()) - .SelectMany(x => x.ToObservable()) - .Where(suggestion => !String.IsNullOrEmpty(suggestion.Name)) // Just being extra cautious - .Select(suggestion => - new AutoCompleteSuggestion(suggestion.Name, suggestion.Description, resolveImage(suggestion.IconKey), Prefix)); + return Observable.FromAsync(async () => + { + var connection = await graphqlFactory.CreateConnection(hostAddress); + var suggestions = await connection.Run(query, variables); + return suggestions.Select(suggestion => new AutoCompleteSuggestion(suggestion.Name, + suggestion.Description, + ResolveImage(suggestion), + Prefix)); + }).SelectMany(enumerable => enumerable); } - public string Prefix { get { return "@"; } } - - IImageCache ImageCache { get { return imageCache.Value; } } + IObservable ResolveImage(SuggestionItem uri) + { + if (uri.ImageUrl != null) + { + return avatarProvider.GetAvatar(uri.ImageUrl); + } - IMentionsCache MentionsCache { get { return mentionsCache.Value; } } + return Observable.Return(AvatarProvider.CreateBitmapImage(DefaultAvatar)); + } - RepositoryModel CurrentRepository { get { return currentRepositoryState.Value.SelectedRepository; } } + public string Prefix => "@"; } } diff --git a/src/GitHub.App/ViewModels/CommentViewModel.cs b/src/GitHub.App/ViewModels/CommentViewModel.cs index b8a640742b..0a750f08a4 100644 --- a/src/GitHub.App/ViewModels/CommentViewModel.cs +++ b/src/GitHub.App/ViewModels/CommentViewModel.cs @@ -41,11 +41,14 @@ public class CommentViewModel : ViewModelBase, ICommentViewModel /// Initializes a new instance of the class. /// /// The comment service. + /// The auto complete advisor. [ImportingConstructor] - public CommentViewModel(ICommentService commentService) + public CommentViewModel(ICommentService commentService, IAutoCompleteAdvisor autoCompleteAdvisor) { Guard.ArgumentNotNull(commentService, nameof(commentService)); + Guard.ArgumentNotNull(autoCompleteAdvisor, nameof(autoCompleteAdvisor)); + AutoCompleteAdvisor = autoCompleteAdvisor; this.commentService = commentService; var canDeleteObservable = this.WhenAnyValue( @@ -190,6 +193,9 @@ public ICommentThreadViewModel Thread /// public ReactiveCommand Delete { get; } + /// + public IAutoCompleteAdvisor AutoCompleteAdvisor { get; } + /// public Task InitializeAsync( ICommentThreadViewModel thread, diff --git a/src/GitHub.App/ViewModels/Documents/IssueishCommentViewModel.cs b/src/GitHub.App/ViewModels/Documents/IssueishCommentViewModel.cs index 184406eb1a..44db887114 100644 --- a/src/GitHub.App/ViewModels/Documents/IssueishCommentViewModel.cs +++ b/src/GitHub.App/ViewModels/Documents/IssueishCommentViewModel.cs @@ -23,9 +23,10 @@ public sealed class IssueishCommentViewModel : CommentViewModel, IIssueishCommen /// Initializes a new instance of the class. /// /// The comment service. + /// [ImportingConstructor] - public IssueishCommentViewModel(ICommentService commentService) - : base(commentService) + public IssueishCommentViewModel(ICommentService commentService, IAutoCompleteAdvisor autoCompleteAdvisor) + : base(commentService, autoCompleteAdvisor) { CloseOrReopen = ReactiveCommand.CreateFromTask( DoCloseOrReopen, diff --git a/src/GitHub.App/ViewModels/GitHubPane/PullRequestCreationViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestCreationViewModel.cs index 89f8e37c50..bd25c85c52 100644 --- a/src/GitHub.App/ViewModels/GitHubPane/PullRequestCreationViewModel.cs +++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestCreationViewModel.cs @@ -18,6 +18,7 @@ using GitHub.Models.Drafts; using GitHub.Primitives; using GitHub.Services; +using GitHub.UI; using GitHub.Validation; using Octokit; using ReactiveUI; @@ -51,8 +52,9 @@ public PullRequestCreationViewModel( IPullRequestService service, INotificationService notifications, IMessageDraftStore draftStore, - IGitService gitService) - : this(modelServiceFactory, service, notifications, draftStore, gitService, DefaultScheduler.Instance) + IGitService gitService, + IAutoCompleteAdvisor autoCompleteAdvisor) + : this(modelServiceFactory, service, notifications, draftStore, gitService, autoCompleteAdvisor, DefaultScheduler.Instance) { } @@ -62,6 +64,7 @@ public PullRequestCreationViewModel( INotificationService notifications, IMessageDraftStore draftStore, IGitService gitService, + IAutoCompleteAdvisor autoCompleteAdvisor, IScheduler timerScheduler) { Guard.ArgumentNotNull(modelServiceFactory, nameof(modelServiceFactory)); @@ -69,12 +72,14 @@ public PullRequestCreationViewModel( Guard.ArgumentNotNull(notifications, nameof(notifications)); Guard.ArgumentNotNull(draftStore, nameof(draftStore)); Guard.ArgumentNotNull(gitService, nameof(gitService)); + Guard.ArgumentNotNull(autoCompleteAdvisor, nameof(autoCompleteAdvisor)); Guard.ArgumentNotNull(timerScheduler, nameof(timerScheduler)); this.service = service; this.modelServiceFactory = modelServiceFactory; this.draftStore = draftStore; this.gitService = gitService; + this.AutoCompleteAdvisor = autoCompleteAdvisor; this.timerScheduler = timerScheduler; this.WhenAnyValue(x => x.Branches) @@ -336,6 +341,7 @@ protected string GetDraftKey() public RemoteRepositoryModel GitHubRepository { get { return githubRepository?.Value; } } bool IsExecuting { get { return isExecuting.Value; } } + public IAutoCompleteAdvisor AutoCompleteAdvisor { get; } bool initialized; bool Initialized diff --git a/src/GitHub.App/ViewModels/GitHubPane/PullRequestReviewAuthoringViewModel.cs b/src/GitHub.App/ViewModels/GitHubPane/PullRequestReviewAuthoringViewModel.cs index c263af4585..82ff54359c 100644 --- a/src/GitHub.App/ViewModels/GitHubPane/PullRequestReviewAuthoringViewModel.cs +++ b/src/GitHub.App/ViewModels/GitHubPane/PullRequestReviewAuthoringViewModel.cs @@ -45,8 +45,9 @@ public PullRequestReviewAuthoringViewModel( IPullRequestEditorService editorService, IPullRequestSessionManager sessionManager, IMessageDraftStore draftStore, - IPullRequestFilesViewModel files) - : this(pullRequestService, editorService, sessionManager,draftStore, files, DefaultScheduler.Instance) + IPullRequestFilesViewModel files, + IAutoCompleteAdvisor autoCompleteAdvisor) + : this(pullRequestService, editorService, sessionManager,draftStore, files, autoCompleteAdvisor, DefaultScheduler.Instance) { } @@ -56,12 +57,14 @@ public PullRequestReviewAuthoringViewModel( IPullRequestSessionManager sessionManager, IMessageDraftStore draftStore, IPullRequestFilesViewModel files, + IAutoCompleteAdvisor autoCompleteAdvisor, IScheduler timerScheduler) { Guard.ArgumentNotNull(editorService, nameof(editorService)); Guard.ArgumentNotNull(sessionManager, nameof(sessionManager)); Guard.ArgumentNotNull(draftStore, nameof(draftStore)); Guard.ArgumentNotNull(files, nameof(files)); + Guard.ArgumentNotNull(autoCompleteAdvisor, nameof(autoCompleteAdvisor)); Guard.ArgumentNotNull(timerScheduler, nameof(timerScheduler)); this.pullRequestService = pullRequestService; @@ -77,6 +80,7 @@ public PullRequestReviewAuthoringViewModel( .ToProperty(this, x => x.CanApproveRequestChanges); Files = files; + AutoCompleteAdvisor = autoCompleteAdvisor; var hasBodyOrComments = this.WhenAnyValue( x => x.Body, @@ -118,6 +122,9 @@ public PullRequestDetailModel PullRequestModel /// public IPullRequestFilesViewModel Files { get; } + /// + public IAutoCompleteAdvisor AutoCompleteAdvisor { get; } + /// public string Body { diff --git a/src/GitHub.App/ViewModels/PullRequestReviewCommentViewModel.cs b/src/GitHub.App/ViewModels/PullRequestReviewCommentViewModel.cs index 1d47330c88..4a2c1a8e97 100644 --- a/src/GitHub.App/ViewModels/PullRequestReviewCommentViewModel.cs +++ b/src/GitHub.App/ViewModels/PullRequestReviewCommentViewModel.cs @@ -25,10 +25,12 @@ public class PullRequestReviewCommentViewModel : CommentViewModel, IPullRequestR /// /// Initializes a new instance of the class. /// - /// The comment service + /// The comment service. + /// The auto complete advisor. [ImportingConstructor] - public PullRequestReviewCommentViewModel(ICommentService commentService) - : base(commentService) + public PullRequestReviewCommentViewModel(ICommentService commentService, + IAutoCompleteAdvisor autoCompleteAdvisor) + : base(commentService, autoCompleteAdvisor) { canStartReview = this.WhenAnyValue( x => x.IsPending, diff --git a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestCreationViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestCreationViewModel.cs index a072853cbe..fb201f3ab7 100644 --- a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestCreationViewModel.cs +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestCreationViewModel.cs @@ -4,6 +4,7 @@ using ReactiveUI; using System.Threading.Tasks; using System.Reactive; +using GitHub.Services; namespace GitHub.ViewModels.GitHubPane { @@ -16,6 +17,8 @@ public interface IPullRequestCreationViewModel : IPanePageViewModel ReactiveCommand Cancel { get; } string PRTitle { get; set; } ReactivePropertyValidator TitleValidator { get; } + IAutoCompleteAdvisor AutoCompleteAdvisor { get; } + Task InitializeAsync(LocalRepositoryModel repository, IConnection connection); } } diff --git a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestReviewAuthoringViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestReviewAuthoringViewModel.cs index e0be2242d9..6d6137050a 100644 --- a/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestReviewAuthoringViewModel.cs +++ b/src/GitHub.Exports.Reactive/ViewModels/GitHubPane/IPullRequestReviewAuthoringViewModel.cs @@ -3,6 +3,7 @@ using System.Reactive; using System.Threading.Tasks; using GitHub.Models; +using GitHub.Services; using ReactiveUI; namespace GitHub.ViewModels.GitHubPane @@ -87,6 +88,11 @@ public interface IPullRequestReviewAuthoringViewModel : IPanePageViewModel, IDis /// ReactiveCommand Cancel { get; } + /// + /// Provides an AutoCompleteAdvisor. + /// + IAutoCompleteAdvisor AutoCompleteAdvisor { get; } + /// /// Initializes the view model for creating a new review. /// diff --git a/src/GitHub.Exports.Reactive/ViewModels/ICommentViewModel.cs b/src/GitHub.Exports.Reactive/ViewModels/ICommentViewModel.cs index ce35d8a3ff..be6d97e13b 100644 --- a/src/GitHub.Exports.Reactive/ViewModels/ICommentViewModel.cs +++ b/src/GitHub.Exports.Reactive/ViewModels/ICommentViewModel.cs @@ -1,5 +1,6 @@ using System; using System.Reactive; +using GitHub.Services; using ReactiveUI; namespace GitHub.ViewModels @@ -119,5 +120,10 @@ public interface ICommentViewModel : IViewModel /// Deletes a comment. /// ReactiveCommand Delete { get; } + + /// + /// Provides an AutoCompleteAdvisor. + /// + IAutoCompleteAdvisor AutoCompleteAdvisor { get; } } } \ No newline at end of file diff --git a/src/GitHub.Extensions/ReflectionExtensions.cs b/src/GitHub.Extensions/ReflectionExtensions.cs index e69c65f1d5..df10dba2f4 100644 --- a/src/GitHub.Extensions/ReflectionExtensions.cs +++ b/src/GitHub.Extensions/ReflectionExtensions.cs @@ -4,6 +4,7 @@ using System.Globalization; using System.Linq; using System.Reflection; +using System.Runtime.Serialization; namespace GitHub.Extensions { @@ -53,5 +54,20 @@ public static string GetCustomAttributeValue(this Assembly assembly, string p var value = propertyInfo.GetValue(attribute, null); return value.ToString(); } + + public static T CreateUninitialized() + { + // WARNING: THIS METHOD IS PURE EVIL! + // Only use this in cases where T is sealed and has an internal ctor and + // you're SURE the API you're passing it into won't do anything interesting with it. + // Even then, consider refactoring. + return (T)FormatterServices.GetUninitializedObject(typeof(T)); + } + + public static void Invoke(object obj, string methodName, params object[] parameters) + { + var method = obj.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic); + method.Invoke(obj, parameters); + } } } diff --git a/src/GitHub.UI/Assets/Controls/AutoCompleteBox.xaml b/src/GitHub.UI/Assets/Controls/AutoCompleteBox.xaml index 1a1a950e6b..6d10b00ade 100644 --- a/src/GitHub.UI/Assets/Controls/AutoCompleteBox.xaml +++ b/src/GitHub.UI/Assets/Controls/AutoCompleteBox.xaml @@ -9,6 +9,7 @@ xmlns:sys="clr-namespace:System;assembly=mscorlib" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" + xmlns:vs="clr-namespace:Microsoft.VisualStudio.PlatformUI;assembly=Microsoft.VisualStudio.Shell.14.0" xmlns:ui="clr-namespace:GitHub.UI"> @@ -59,62 +60,51 @@ - + + + + + + - - - - - + + + + + + + + + + + + + + + + + + - - + + + + + + + + + + + + + - + + + + + + + + + + + + + Submit review diff --git a/src/GitHub.VisualStudio.UI/Views/PullRequestReviewCommentView.xaml b/src/GitHub.VisualStudio.UI/Views/PullRequestReviewCommentView.xaml index 41597a44dc..eab0a78381 100644 --- a/src/GitHub.VisualStudio.UI/Views/PullRequestReviewCommentView.xaml +++ b/src/GitHub.VisualStudio.UI/Views/PullRequestReviewCommentView.xaml @@ -161,39 +161,51 @@ - - - - - - - + + + + + + + + + ()) + : base(Substitute.For(), Substitute.For()) { } diff --git a/test/GitHub.App.UnitTests/ViewModels/CommentViewModelTests.cs b/test/GitHub.App.UnitTests/ViewModels/CommentViewModelTests.cs index 6fa4e42632..786719d517 100644 --- a/test/GitHub.App.UnitTests/ViewModels/CommentViewModelTests.cs +++ b/test/GitHub.App.UnitTests/ViewModels/CommentViewModelTests.cs @@ -65,11 +65,14 @@ await target.InitializeAsync( } CommentViewModel CreateTarget( - ICommentService commentService = null) + ICommentService commentService = null, + IAutoCompleteAdvisor autoCompleteAdvisor = null + ) { commentService = commentService ?? Substitute.For(); + autoCompleteAdvisor = autoCompleteAdvisor ?? Substitute.For(); - return new CommentViewModel(commentService); + return new CommentViewModel(commentService, autoCompleteAdvisor); } } } diff --git a/test/GitHub.App.UnitTests/ViewModels/Documents/IssueishCommentViewModelTests.cs b/test/GitHub.App.UnitTests/ViewModels/Documents/IssueishCommentViewModelTests.cs index f925182bee..cee68ba3d5 100644 --- a/test/GitHub.App.UnitTests/ViewModels/Documents/IssueishCommentViewModelTests.cs +++ b/test/GitHub.App.UnitTests/ViewModels/Documents/IssueishCommentViewModelTests.cs @@ -54,11 +54,13 @@ await target.InitializeAsync( } IssueishCommentViewModel CreateTarget( - ICommentService commentService = null) + ICommentService commentService = null, + IAutoCompleteAdvisor autoCompleteAdvisor = null) { commentService = commentService ?? Substitute.For(); + autoCompleteAdvisor = autoCompleteAdvisor ?? Substitute.For(); - return new IssueishCommentViewModel(commentService); + return new IssueishCommentViewModel(commentService, autoCompleteAdvisor); } } } diff --git a/test/GitHub.App.UnitTests/ViewModels/GitHubPane/PullRequestCreationViewModelTests.cs b/test/GitHub.App.UnitTests/ViewModels/GitHubPane/PullRequestCreationViewModelTests.cs index 4e8d1ca8b6..2c2df0a648 100644 --- a/test/GitHub.App.UnitTests/ViewModels/GitHubPane/PullRequestCreationViewModelTests.cs +++ b/test/GitHub.App.UnitTests/ViewModels/GitHubPane/PullRequestCreationViewModelTests.cs @@ -55,6 +55,7 @@ struct TestData public IConnection Connection; public IApiClient ApiClient; public IModelService ModelService; + public IAutoCompleteAdvisor AutoCompleteAdvisor { get; set; } public IModelServiceFactory GetModelServiceFactory() { @@ -78,6 +79,7 @@ static TestData PrepareTestData( var connection = Substitute.For(); var api = Substitute.For(); var ms = Substitute.For(); + var autoCompleteAdvisor = Substitute.For(); connection.HostAddress.Returns(HostAddress.Create("https://github.com")); @@ -121,7 +123,8 @@ static TestData PrepareTestData( NotificationService = notifications, Connection = connection, ApiClient = api, - ModelService = ms + ModelService = ms, + AutoCompleteAdvisor = autoCompleteAdvisor }; } @@ -147,7 +150,7 @@ public async Task TargetBranchDisplayNameIncludesRepoOwnerWhenForkAsync() var prservice = new PullRequestService(data.GitClient, data.GitService, Substitute.For(), Substitute.For(), Substitute.For(), data.ServiceProvider.GetOperatingSystem(), Substitute.For()); prservice.GetPullRequestTemplate(data.ActiveRepo).Returns(Observable.Empty()); var vm = new PullRequestCreationViewModel(data.GetModelServiceFactory(), prservice, data.NotificationService, - Substitute.For(), data.GitService); + Substitute.For(), data.GitService, data.AutoCompleteAdvisor); await vm.InitializeAsync(data.ActiveRepo, data.Connection); Assert.That("octokit/master", Is.EqualTo(vm.TargetBranch.DisplayName)); } @@ -183,7 +186,7 @@ public async Task CreatingPRsAsync( var prservice = new PullRequestService(data.GitClient, data.GitService, Substitute.For(), Substitute.For(), Substitute.For(), data.ServiceProvider.GetOperatingSystem(), Substitute.For()); var vm = new PullRequestCreationViewModel(data.GetModelServiceFactory(), prservice, data.NotificationService, - Substitute.For(), data.GitService); + Substitute.For(), data.GitService, data.AutoCompleteAdvisor); await vm.InitializeAsync(data.ActiveRepo, data.Connection); // the TargetBranch property gets set to whatever the repo default is (we assume master here), @@ -226,7 +229,7 @@ public async Task TemplateIsUsedIfPresentAsync() prservice.GetPullRequestTemplate(data.ActiveRepo).Returns(Observable.Return("Test PR template")); var vm = new PullRequestCreationViewModel(data.GetModelServiceFactory(), prservice, data.NotificationService, - Substitute.For(), data.GitService); + Substitute.For(), data.GitService, data.AutoCompleteAdvisor); await vm.InitializeAsync(data.ActiveRepo, data.Connection); Assert.That("Test PR template", Is.EqualTo(vm.Description)); @@ -246,7 +249,7 @@ public async Task LoadsDraft() var prservice = Substitute.For(); var vm = new PullRequestCreationViewModel(data.GetModelServiceFactory(), prservice, data.NotificationService, - draftStore, data.GitService); + draftStore, data.GitService, data.AutoCompleteAdvisor); await vm.InitializeAsync(data.ActiveRepo, data.Connection); Assert.That(vm.PRTitle, Is.EqualTo("This is a Title.")); @@ -261,7 +264,7 @@ public async Task UpdatesDraftWhenDescriptionChanges() var draftStore = Substitute.For(); var prservice = Substitute.For(); var vm = new PullRequestCreationViewModel(data.GetModelServiceFactory(), prservice, data.NotificationService, - draftStore, data.GitService, scheduler); + draftStore, data.GitService, data.AutoCompleteAdvisor, scheduler); await vm.InitializeAsync(data.ActiveRepo, data.Connection); vm.Description = "Body changed."; @@ -284,7 +287,7 @@ public async Task UpdatesDraftWhenTitleChanges() var draftStore = Substitute.For(); var prservice = Substitute.For(); var vm = new PullRequestCreationViewModel(data.GetModelServiceFactory(), prservice, data.NotificationService, - draftStore, data.GitService, scheduler); + draftStore, data.GitService, data.AutoCompleteAdvisor, scheduler); await vm.InitializeAsync(data.ActiveRepo, data.Connection); vm.PRTitle = "Title changed."; @@ -307,7 +310,7 @@ public async Task DeletesDraftWhenPullRequestSubmitted() var draftStore = Substitute.For(); var prservice = Substitute.For(); var vm = new PullRequestCreationViewModel(data.GetModelServiceFactory(), prservice, data.NotificationService, draftStore, - data.GitService, scheduler); + data.GitService, data.AutoCompleteAdvisor, scheduler); await vm.InitializeAsync(data.ActiveRepo, data.Connection); await vm.CreatePullRequest.Execute(); @@ -323,7 +326,7 @@ public async Task DeletesDraftWhenCanceled() var draftStore = Substitute.For(); var prservice = Substitute.For(); var vm = new PullRequestCreationViewModel(data.GetModelServiceFactory(), prservice, data.NotificationService, draftStore, - data.GitService, scheduler); + data.GitService, data.AutoCompleteAdvisor, scheduler); await vm.InitializeAsync(data.ActiveRepo, data.Connection); await vm.Cancel.Execute(); diff --git a/test/GitHub.App.UnitTests/ViewModels/GitHubPane/PullRequestReviewAuthoringViewModelTests.cs b/test/GitHub.App.UnitTests/ViewModels/GitHubPane/PullRequestReviewAuthoringViewModelTests.cs index 42c876cb35..f2d4d2925a 100644 --- a/test/GitHub.App.UnitTests/ViewModels/GitHubPane/PullRequestReviewAuthoringViewModelTests.cs +++ b/test/GitHub.App.UnitTests/ViewModels/GitHubPane/PullRequestReviewAuthoringViewModelTests.cs @@ -487,12 +487,15 @@ static PullRequestReviewAuthoringViewModel CreateTarget( IPullRequestSessionManager sessionManager = null, IMessageDraftStore draftStore = null, IPullRequestFilesViewModel files = null, - IScheduler timerScheduler = null) + IScheduler timerScheduler = null, + IAutoCompleteAdvisor autoCompleteAdvisor = null + ) { editorService = editorService ?? Substitute.For(); sessionManager = sessionManager ?? CreateSessionManager(); draftStore = draftStore ?? Substitute.For(); files = files ?? Substitute.For(); + autoCompleteAdvisor = autoCompleteAdvisor ?? Substitute.For(); timerScheduler = timerScheduler ?? DefaultScheduler.Instance; return new PullRequestReviewAuthoringViewModel( @@ -501,6 +504,7 @@ static PullRequestReviewAuthoringViewModel CreateTarget( sessionManager, draftStore, files, + autoCompleteAdvisor, timerScheduler); } diff --git a/test/GitHub.App.UnitTests/ViewModels/PullRequestReviewCommentThreadViewModelTests.cs b/test/GitHub.App.UnitTests/ViewModels/PullRequestReviewCommentThreadViewModelTests.cs index 72e2c88536..2420120dea 100644 --- a/test/GitHub.App.UnitTests/ViewModels/PullRequestReviewCommentThreadViewModelTests.cs +++ b/test/GitHub.App.UnitTests/ViewModels/PullRequestReviewCommentThreadViewModelTests.cs @@ -172,8 +172,9 @@ static IViewViewModelFactory CreateFactory() { var result = Substitute.For(); var commentService = Substitute.For(); + var autoCompleteAdvisor = Substitute.For(); result.CreateViewModel().Returns(_ => - new PullRequestReviewCommentViewModel(commentService)); + new PullRequestReviewCommentViewModel(commentService, autoCompleteAdvisor)); return result; } diff --git a/test/GitHub.App.UnitTests/ViewModels/PullRequestReviewCommentViewModelTests.cs b/test/GitHub.App.UnitTests/ViewModels/PullRequestReviewCommentViewModelTests.cs index 838d6fc8ca..d6d2fe8043 100644 --- a/test/GitHub.App.UnitTests/ViewModels/PullRequestReviewCommentViewModelTests.cs +++ b/test/GitHub.App.UnitTests/ViewModels/PullRequestReviewCommentViewModelTests.cs @@ -54,7 +54,8 @@ public async Task CanBeExecutedForPlaceholders() var thread = CreateThread(); var currentUser = Substitute.For(); var commentService = Substitute.For(); - var target = new PullRequestReviewCommentViewModel(commentService); + var autoCompleteAdvisor = Substitute.For(); + var target = new PullRequestReviewCommentViewModel(commentService, autoCompleteAdvisor); await target.InitializeAsPlaceholderAsync(session, thread, false, false); @@ -98,7 +99,8 @@ public async Task CannotBeExecutedForPlaceholders() var thread = CreateThread(); var currentUser = Substitute.For(); var commentService = Substitute.For(); - var target = new PullRequestReviewCommentViewModel(commentService); + var autoCompleteAdvisor = Substitute.For(); + var target = new PullRequestReviewCommentViewModel(commentService, autoCompleteAdvisor); await target.InitializeAsPlaceholderAsync(session, thread, false, false); @@ -218,16 +220,18 @@ static async Task CreateTarget( ICommentThreadViewModel thread = null, ActorModel currentUser = null, PullRequestReviewModel review = null, - PullRequestReviewCommentModel comment = null) + PullRequestReviewCommentModel comment = null, + IAutoCompleteAdvisor autoCompleteAdvisor = null) { session = session ?? CreateSession(); commentService = commentService ?? Substitute.For(); + autoCompleteAdvisor = autoCompleteAdvisor ?? Substitute.For(); thread = thread ?? CreateThread(); currentUser = currentUser ?? new ActorModel { Login = "CurrentUser" }; comment = comment ?? new PullRequestReviewCommentModel(); review = review ?? CreateReview(PullRequestReviewState.Approved, comment); - var result = new PullRequestReviewCommentViewModel(commentService); + var result = new PullRequestReviewCommentViewModel(commentService, autoCompleteAdvisor); await result.InitializeAsync(session, thread, review, comment, CommentEditState.None); return result; } diff --git a/test/GitHub.InlineReviews.UnitTests/ViewModels/InlineCommentPeekViewModelTests.cs b/test/GitHub.InlineReviews.UnitTests/ViewModels/InlineCommentPeekViewModelTests.cs index bbdf4668c2..5e9ac069e6 100644 --- a/test/GitHub.InlineReviews.UnitTests/ViewModels/InlineCommentPeekViewModelTests.cs +++ b/test/GitHub.InlineReviews.UnitTests/ViewModels/InlineCommentPeekViewModelTests.cs @@ -272,8 +272,9 @@ static IViewViewModelFactory CreateFactory() var draftStore = Substitute.For(); var commentService = Substitute.For(); var result = Substitute.For(); + var autoCompleteAdvisor = Substitute.For(); result.CreateViewModel().Returns(_ => - new PullRequestReviewCommentViewModel(commentService)); + new PullRequestReviewCommentViewModel(commentService, autoCompleteAdvisor)); result.CreateViewModel().Returns(_ => new PullRequestReviewCommentThreadViewModel(draftStore, result)); return result; diff --git a/test/GitHub.UI.UnitTests/Controls/AutoCompleteBoxTests.cs b/test/GitHub.UI.UnitTests/Controls/AutoCompleteBoxTests.cs index ace2f2b467..33baca44a3 100644 --- a/test/GitHub.UI.UnitTests/Controls/AutoCompleteBoxTests.cs +++ b/test/GitHub.UI.UnitTests/Controls/AutoCompleteBoxTests.cs @@ -1,212 +1,293 @@ using System.Collections.Generic; using System.Collections.ObjectModel; +using System.Linq; using System.Reactive.Linq; +using System.Threading; +using System.Windows; using System.Windows.Controls; using System.Windows.Input; using System.Windows.Media.Imaging; -using GitHub.Tests.TestHelpers; -using GitHub.UI; +using GitHub.Extensions; +using GitHub.Models; +using GitHub.Services; using GitHub.UI.Helpers; -using Moq; -using Xunit; +using NSubstitute; +using NUnit.Framework; -public class AutoCompleteBoxTests +namespace GitHub.UI.UnitTests.Controls { - public class TheItemsSourceProperty + public class AutoCompleteBoxTests { - [STAFact] - public void SelectsFirstItemWhenSetToNonEmptyCollection() + [Apartment(ApartmentState.STA)] + public class TheItemsSourceProperty { - var obs = Observable.Return(new BitmapImage()); - - var suggestions = new List - { - new AutoCompleteSuggestion("aaaa", obs, ":", ":"), - new AutoCompleteSuggestion("bbbb", obs, ":", ":"), - new AutoCompleteSuggestion("ccc", obs, ":", ":") - }; - var result = new AutoCompleteResult(1, new ReadOnlyCollection(suggestions)); - var advisor = Mock.Of( - a => a.GetAutoCompletionSuggestions(Args.String, Args.Int32) == Observable.Return(result)); - var textBox = new TextBox(); - var autoCompleteBox = new AutoCompleteBox(Mock.Of()) + [Test] + public void SelectsFirstItemWhenSetToNonEmptyCollection() { - SelectionAdapter = new SelectorSelectionAdapter(new ListBox()), - Advisor = advisor, - TextBox = new TextBoxAutoCompleteTextInput { TextBox = textBox } - }; + var obs = Observable.Return(new BitmapImage()); + + var suggestions = new List + { + new AutoCompleteSuggestion("aaaa", obs, ":", ":"), + new AutoCompleteSuggestion("bbbb", obs, ":", ":"), + new AutoCompleteSuggestion("ccc", obs, ":", ":") + }; + var result = new AutoCompleteResult(1, new ReadOnlyCollection(suggestions)); + var advisor = Substitute.For(); + advisor.GetAutoCompletionSuggestions(Arg.Any(), Arg.Any()) + .Returns(Observable.Return(result)); + + var textBox = new TextBox(); + var autoCompleteBox = new AutoCompleteBox(Substitute.For()) + { + SelectionAdapter = new SelectorSelectionAdapter(new ListBox()), + Advisor = advisor, + TextBox = new TextBoxAutoCompleteTextInput { TextBox = textBox } + }; - textBox.Text = ":"; + textBox.Text = ":"; - Assert.Equal("aaaa", ((AutoCompleteSuggestion) autoCompleteBox.SelectedItem).Name); - Assert.Equal(":", autoCompleteBox.Text); // It should not have expanded it yet + Assert.That(((AutoCompleteSuggestion)autoCompleteBox.SelectedItem).Name, Is.EqualTo("aaaa")); + Assert.That(autoCompleteBox.Text, Is.EqualTo(":")); + } } - } - public class TheIsDropDownOpenProperty - { - [STAFact] - public void IsTrueWhenTextBoxChangesWithPrefixedValue() + [Apartment(ApartmentState.STA)] + public class TheIsDropDownOpenProperty { - var obs = Observable.Return(new BitmapImage()); + [Test] + public void IsTrueWhenTextBoxChangesWithPrefixedValue() + { + var obs = Observable.Return(new BitmapImage()); + + var suggestions = new List + { + new AutoCompleteSuggestion("aaaa", obs, ":", ":"), + new AutoCompleteSuggestion("bbbb", obs, ":", ":"), + new AutoCompleteSuggestion("ccc", obs, ":", ":") + }; + var result = new AutoCompleteResult(0, new ReadOnlyCollection(suggestions)); + var advisor = Substitute.For(); + advisor.GetAutoCompletionSuggestions(Arg.Any(), Arg.Any()) + .Returns(Observable.Return(result)); + + var textBox = new TextBox(); + var autoCompleteBox = new AutoCompleteBox(Substitute.For()) + { + SelectionAdapter = new SelectorSelectionAdapter(new ListBox()), + Advisor = advisor, + TextBox = new TextBoxAutoCompleteTextInput { TextBox = textBox } + }; + + textBox.Text = ":"; + + Assert.True(autoCompleteBox.IsDropDownOpen); + } - var suggestions = new List + [Test] + public void IsFalseAfterASuggestionIsSelected() { - new AutoCompleteSuggestion("aaaa", obs, ":", ":"), - new AutoCompleteSuggestion("bbbb", obs, ":", ":"), - new AutoCompleteSuggestion("ccc", obs, ":", ":") - }; - var result = new AutoCompleteResult(0, new ReadOnlyCollection(suggestions)); - var advisor = Mock.Of( - a => a.GetAutoCompletionSuggestions(Args.String, Args.Int32) == Observable.Return(result)); - var textBox = new TextBox(); - var autoCompleteBox = new AutoCompleteBox(Mock.Of()) + var obs = Observable.Return(new BitmapImage()); + + var suggestions = new List + { + new AutoCompleteSuggestion("aaaa", obs, ":", ":"), + new AutoCompleteSuggestion("bbbb", obs, ":", ":"), + new AutoCompleteSuggestion("ccc", obs, ":", ":") + }; + var result = new AutoCompleteResult(2, new ReadOnlyCollection(suggestions)); + var advisor = Substitute.For(); + advisor.GetAutoCompletionSuggestions(Arg.Any(), Arg.Any()) + .Returns(Observable.Return(result)); + + var selectionAdapter = new TestSelectorSelectionAdapter(); + var textBox = new TextBox(); + var autoCompleteBox = new AutoCompleteBox(Substitute.For()) + { + SelectionAdapter = selectionAdapter, + Advisor = advisor, + TextBox = new TextBoxAutoCompleteTextInput {TextBox = textBox} + }; + textBox.Text = "A :a"; + textBox.CaretIndex = 4; + Assert.AreEqual(4, textBox.CaretIndex); + Assert.AreEqual(4, autoCompleteBox.TextBox.CaretIndex); + Assert.True(autoCompleteBox.IsDropDownOpen); + + selectionAdapter.DoCommit(); + + Assert.That(textBox.Text, Is.EqualTo("A :aaaa: ")); + Assert.False(autoCompleteBox.IsDropDownOpen); + } + + [Test] + public void IsFalseAfterASuggestionIsCancelled() { - SelectionAdapter = new SelectorSelectionAdapter(new ListBox()), - Advisor = advisor, - TextBox = new TextBoxAutoCompleteTextInput { TextBox = textBox } - }; + var obs = Observable.Return(new BitmapImage()); - textBox.Text = ":"; + var suggestions = new List + { + new AutoCompleteSuggestion("aaaa", obs, ":", ":"), + new AutoCompleteSuggestion("bbbb", obs, ":", ":"), + new AutoCompleteSuggestion("ccc", obs, ":", ":") + }; + var result = new AutoCompleteResult(2, new ReadOnlyCollection(suggestions)); + var advisor = Substitute.For(); + advisor.GetAutoCompletionSuggestions(Arg.Any(), Arg.Any()) + .Returns(Observable.Return(result)); - Assert.True(autoCompleteBox.IsDropDownOpen); - } + var selectionAdapter = new TestSelectorSelectionAdapter(); + var textBox = new TextBox(); + var autoCompleteBox = new AutoCompleteBox(Substitute.For()) + { + SelectionAdapter = selectionAdapter, + Advisor = advisor, + TextBox = new TextBoxAutoCompleteTextInput { TextBox = textBox } + }; + textBox.Text = "A :a"; + textBox.CaretIndex = 4; + Assert.AreEqual(4, textBox.CaretIndex); + Assert.AreEqual(4, autoCompleteBox.TextBox.CaretIndex); + Assert.True(autoCompleteBox.IsDropDownOpen); - [STAFact] - public void IsFalseAfterASuggestionIsSelected() - { - var obs = Observable.Return(new BitmapImage()); + selectionAdapter.DoCancel(); + + Assert.That(textBox.Text, Is.EqualTo("A :a")); + Assert.False(autoCompleteBox.IsDropDownOpen); + } - var suggestions = new List + [Test] + public void HandlesKeyPressesToSelectAndCancelSelections() { - new AutoCompleteSuggestion("aaaa", obs, ":", ":"), - new AutoCompleteSuggestion("bbbb", obs, ":", ":"), - new AutoCompleteSuggestion("ccc", obs, ":", ":") - }; - var result = new AutoCompleteResult(2, new ReadOnlyCollection(suggestions)); - var advisor = Mock.Of( - a => a.GetAutoCompletionSuggestions(Args.String, Args.Int32) == Observable.Return(result)); - var selectionAdapter = new TestSelectorSelectionAdapter(); - var textBox = new TextBox(); - var autoCompleteBox = new AutoCompleteBox(Mock.Of()) + var obs = Observable.Return(new BitmapImage()); + + var suggestions = new List + { + new AutoCompleteSuggestion("aaaa", obs, ":", ":"), + new AutoCompleteSuggestion("bbbb", obs, ":", ":"), + new AutoCompleteSuggestion("ccc", obs, ":", ":") + }; + var result = new AutoCompleteResult(2, new ReadOnlyCollection(suggestions)); + var advisor = Substitute.For(); + advisor.GetAutoCompletionSuggestions(Arg.Any(), Arg.Any()) + .Returns(Observable.Return(result)); + + var selectionAdapter = new TestSelectorSelectionAdapter(); + var textBox = new TextBox(); + var autoCompleteBox = new AutoCompleteBox(Substitute.For()) + { + SelectionAdapter = selectionAdapter, + Advisor = advisor, + TextBox = new TextBoxAutoCompleteTextInput { TextBox = textBox } + }; + textBox.Text = "A :a"; + textBox.CaretIndex = 4; + Assert.AreEqual(4, textBox.CaretIndex); + Assert.AreEqual(4, autoCompleteBox.TextBox.CaretIndex); + Assert.True(autoCompleteBox.IsDropDownOpen); + selectionAdapter.SelectorControl.SelectedIndex = 1; // Select the second item + + selectionAdapter.DoKeyDown(Key.Enter); + + Assert.AreEqual("A :bbbb: ", textBox.Text); + Assert.False(autoCompleteBox.IsDropDownOpen); + + textBox.Text = "A :bbbb: :"; + textBox.CaretIndex = 10; + + // Ensure we can re-open the dropdown + Assert.True(autoCompleteBox.IsDropDownOpen); + + selectionAdapter.DoKeyDown(Key.Escape); + Assert.False(autoCompleteBox.IsDropDownOpen); + Assert.AreEqual("A :bbbb: :", textBox.Text); + } + + class TestSelectorSelectionAdapter : SelectorSelectionAdapter { - SelectionAdapter = selectionAdapter, - Advisor = advisor, - TextBox = new TextBoxAutoCompleteTextInput {TextBox = textBox} - }; - textBox.Text = "A :a"; - textBox.CaretIndex = 4; - Assert.Equal(4, textBox.CaretIndex); - Assert.Equal(4, autoCompleteBox.TextBox.CaretIndex); - Assert.True(autoCompleteBox.IsDropDownOpen); - - selectionAdapter.DoCommit(); - - Assert.Equal("A :aaaa: ", textBox.Text); - Assert.False(autoCompleteBox.IsDropDownOpen); + public TestSelectorSelectionAdapter() + : base(new ListBox()) + { + } + + public void DoCommit() + { + base.OnCommit(); + } + + public void DoCancel() + { + base.OnCancel(); + } + + public void DoKeyDown(Key key) + { + var keyEventArgs = FakeKeyEventArgs.Create(key, false); + HandleKeyDown(keyEventArgs); + } + } } - [STAFact] - public void IsFalseAfterASuggestionIsCancelled() + public class FakeKeyEventArgs : KeyEventArgs { - var obs = Observable.Return(new BitmapImage()); + public static KeyEventArgs Create(Key realKey, bool isSystemKey, params Key[] pressedKeys) + { + return new FakeKeyEventArgs(realKey, isSystemKey, GetKeyStatesFromPressedKeys(pressedKeys)); + } - var suggestions = new List + public static KeyEventArgs Create(Key realKey, params Key[] pressedKeys) { - new AutoCompleteSuggestion("aaaa", obs, ":", ":"), - new AutoCompleteSuggestion("bbbb", obs, ":", ":"), - new AutoCompleteSuggestion("ccc", obs, ":", ":") - }; - var result = new AutoCompleteResult(2, new ReadOnlyCollection(suggestions)); - var advisor = Mock.Of( - a => a.GetAutoCompletionSuggestions(Args.String, Args.Int32) == Observable.Return(result)); - var selectionAdapter = new TestSelectorSelectionAdapter(); - var textBox = new TextBox(); - var autoCompleteBox = new AutoCompleteBox(Mock.Of()) + return new FakeKeyEventArgs(realKey, false, GetKeyStatesFromPressedKeys(pressedKeys)); + } + + FakeKeyEventArgs(Key realKey, bool isSystemKey, IDictionary keyStatesMap) : base(GetKeyboardDevice(keyStatesMap), Substitute.For(), 1, realKey) { - SelectionAdapter = selectionAdapter, - Advisor = advisor, - TextBox = new TextBoxAutoCompleteTextInput { TextBox = textBox } - }; - textBox.Text = "A :a"; - textBox.CaretIndex = 4; - Assert.Equal(4, textBox.CaretIndex); - Assert.Equal(4, autoCompleteBox.TextBox.CaretIndex); - Assert.True(autoCompleteBox.IsDropDownOpen); - - selectionAdapter.DoCancel(); - - Assert.Equal("A :a", textBox.Text); - Assert.False(autoCompleteBox.IsDropDownOpen); - } + if (isSystemKey) + { + MarkSystem(); + } + RoutedEvent = ReflectionExtensions.CreateUninitialized(); + } - [STAFact] - public void HandlesKeyPressesToSelectAndCancelSelections() - { - var obs = Observable.Return(new BitmapImage()); + public void MarkSystem() + { + ReflectionExtensions.Invoke(this, "MarkSystem"); + } - var suggestions = new List + static KeyboardDevice GetKeyboardDevice(IDictionary keyStatesMap) { - new AutoCompleteSuggestion("aaaa", obs, ":", ":"), - new AutoCompleteSuggestion("bbbb", obs, ":", ":"), - new AutoCompleteSuggestion("ccc", obs, ":", ":") - }; - var result = new AutoCompleteResult(2, new ReadOnlyCollection(suggestions)); - var advisor = Mock.Of( - a => a.GetAutoCompletionSuggestions(Args.String, Args.Int32) == Observable.Return(result)); - var selectionAdapter = new TestSelectorSelectionAdapter(); - var textBox = new TextBox(); - var autoCompleteBox = new AutoCompleteBox(Mock.Of()) + return new FakeKeyboardDevice(keyStatesMap); + } + + static IDictionary GetKeyStatesFromPressedKeys(IEnumerable pressedKeys) { - SelectionAdapter = selectionAdapter, - Advisor = advisor, - TextBox = new TextBoxAutoCompleteTextInput { TextBox = textBox } - }; - textBox.Text = "A :a"; - textBox.CaretIndex = 4; - Assert.Equal(4, textBox.CaretIndex); - Assert.Equal(4, autoCompleteBox.TextBox.CaretIndex); - Assert.True(autoCompleteBox.IsDropDownOpen); - selectionAdapter.SelectorControl.SelectedIndex = 1; // Select the second item - - selectionAdapter.DoKeyDown(Key.Enter); - - Assert.Equal("A :bbbb: ", textBox.Text); - Assert.False(autoCompleteBox.IsDropDownOpen); - - textBox.Text = "A :bbbb: :"; - textBox.CaretIndex = 10; - - // Ensure we can re-open the dropdown - Assert.True(autoCompleteBox.IsDropDownOpen); - - selectionAdapter.DoKeyDown(Key.Escape); - Assert.False(autoCompleteBox.IsDropDownOpen); - Assert.Equal("A :bbbb: :", textBox.Text); + return pressedKeys == null ? null : pressedKeys.ToDictionary(k => k, k => KeyStates.Down); + } } - class TestSelectorSelectionAdapter : SelectorSelectionAdapter + public class FakeKeyboardDevice : KeyboardDevice { - public TestSelectorSelectionAdapter() - : base(new ListBox()) - { - } + readonly IDictionary keyStateMap; - public void DoCommit() + public FakeKeyboardDevice(IDictionary keyStateMap) : base(CreateFakeInputManager()) { - base.OnCommit(); + this.keyStateMap = keyStateMap ?? new Dictionary(); } - public void DoCancel() + protected override KeyStates GetKeyStatesFromSystem(Key key) { - base.OnCancel(); + KeyStates keyStates; + keyStateMap.TryGetValue(key, out keyStates); + return keyStates; } - public void DoKeyDown(Key key) + static InputManager CreateFakeInputManager() { - var keyEventArgs = FakeKeyEventArgs.Create(key, false); - HandleKeyDown(keyEventArgs); + Castle.DynamicProxy.Generators.AttributesToAvoidReplicating.Add(typeof(System.Security.Permissions.UIPermissionAttribute)); + // WARNING: This next call is pure evil, but ok here. See the note in the method implementation. + return ReflectionExtensions.CreateUninitialized(); } } + } } diff --git a/test/GitHub.UI.UnitTests/Controls/AutoCompleteSuggestionTests.cs b/test/GitHub.UI.UnitTests/Controls/AutoCompleteSuggestionTests.cs index 66b3d28728..4674997af9 100644 --- a/test/GitHub.UI.UnitTests/Controls/AutoCompleteSuggestionTests.cs +++ b/test/GitHub.UI.UnitTests/Controls/AutoCompleteSuggestionTests.cs @@ -1,58 +1,58 @@ using System.Reactive.Linq; using System.Windows.Media.Imaging; -using GitHub.UI; +using GitHub.Models; using NUnit.Framework; -using Xunit; -public class AutoCompleteSuggestionTests +namespace GitHub.UI.UnitTests.Controls { - public class TheToStringMethod + public class AutoCompleteSuggestionTests { - [Theory] - [InlineData(":", ":", ":foo:")] - [InlineData("@", "", "@foo")] - [InlineData("#", "", "#foo")] - [InlineData("@", null, "@foo")] - public void ReturnsWordSurroundedByPrefixAndSuffix(string prefix, string suffix, string expected) + public class TheToStringMethod { - var obs = Observable.Return(new BitmapImage()); - var suggestion = new AutoCompleteSuggestion("foo", obs, prefix, suffix); - Assert.Equal(expected, suggestion.ToString()); + [TestCase(":", ":", ":foo:")] + [TestCase("@", "", "@foo")] + [TestCase("#", "", "#foo")] + [TestCase("@", null, "@foo")] + public void ReturnsWordSurroundedByPrefixAndSuffix(string prefix, string suffix, string expected) + { + var obs = Observable.Return(new BitmapImage()); + var suggestion = new AutoCompleteSuggestion("foo", obs, prefix, suffix); + Assert.AreEqual(expected, suggestion.ToString()); + } } - } - public class TheGetSortRankMethod - { - [Theory] - [InlineData("pat", "full name", 1)] - [InlineData("yosemite", "pat name", 0)] - [InlineData("minnie", "full pat", 0)] - [InlineData("patrick", "full name", 1)] - [InlineData("groot", "patrick name", 0)] - [InlineData("driver", "danica patrick", 0)] - [InlineData("patricka", "pat name", 1)] - [InlineData("nomatch", "full name", -1)] - public void ReturnsCorrectScoreForSuggestions(string login, string name, int expectedRank) + public class TheGetSortRankMethod { - var obs = Observable.Return(new BitmapImage()); + [TestCase("pat", "full name", 1)] + [TestCase("yosemite", "pat name", 0)] + [TestCase("minnie", "full pat", 0)] + [TestCase("patrick", "full name", 1)] + [TestCase("groot", "patrick name", 0)] + [TestCase("driver", "danica patrick", 0)] + [TestCase("patricka", "pat name", 1)] + [TestCase("nomatch", "full name", -1)] + public void ReturnsCorrectScoreForSuggestions(string login, string name, int expectedRank) + { + var obs = Observable.Return(new BitmapImage()); - var suggestion = new AutoCompleteSuggestion(login, name, obs, "@", ""); + var suggestion = new AutoCompleteSuggestion(login, name, obs, "@", ""); - int rank = suggestion.GetSortRank("pat"); + int rank = suggestion.GetSortRank("pat"); - Assert.Equal(expectedRank, rank); - } + Assert.AreEqual(expectedRank, rank); + } - [Fact] - public void ReturnsOneForEmptyString() - { - var obs = Observable.Return(new BitmapImage()); + [Test] + public void ReturnsOneForEmptyString() + { + var obs = Observable.Return(new BitmapImage()); - var suggestion = new AutoCompleteSuggestion("joe", "namathe", obs, "@", ""); + var suggestion = new AutoCompleteSuggestion("joe", "namathe", obs, "@", ""); - int rank = suggestion.GetSortRank(""); + int rank = suggestion.GetSortRank(""); - Assert.Equal(1, rank); + Assert.AreEqual(1, rank); + } } } } diff --git a/test/GitHub.UI.UnitTests/Controls/AutoCompleteTextInputExtensionsTests.cs b/test/GitHub.UI.UnitTests/Controls/AutoCompleteTextInputExtensionsTests.cs index e53a250818..bb9826a7e9 100644 --- a/test/GitHub.UI.UnitTests/Controls/AutoCompleteTextInputExtensionsTests.cs +++ b/test/GitHub.UI.UnitTests/Controls/AutoCompleteTextInputExtensionsTests.cs @@ -1,25 +1,29 @@ -using GitHub.UI; -using GitHub.UI.Controls.AutoCompleteBox; -using Moq; -using Xunit; +using GitHub.UI.Controls.AutoCompleteBox; +using NSubstitute; +using NUnit.Framework; -class AutoCompleteTextInputExtensionsTests +namespace GitHub.UI.UnitTests.Controls { - public class TheGetExpandedTextMethod + class AutoCompleteTextInputExtensionsTests { - [Theory] - [InlineData(":", 1, 0, ":apple: ")] - [InlineData(":a", 2, 0, ":apple: ")] - [InlineData(":ap", 3, 0, ":apple: ")] - [InlineData(":a", 1, 0, ":apple: a")] - [InlineData("Test :", 6, 5, "Test :apple: ")] - [InlineData("Test :ap", 8, 5, "Test :apple: ")] - [InlineData("Test :apother stuff", 8, 5, "Test :apple: other stuff")] - public void ReturnsExpandedText(string text, int caretIndex, int completionOffset, string expected) + public class TheGetExpandedTextMethod { - var textInput = Mock.Of(t => t.CaretIndex == caretIndex && t.Text == text); - var expandedText = textInput.GetExpandedText(":apple:", completionOffset); - Assert.Equal(expected, expandedText); + [TestCase(":", 1, 0, ":apple: ")] + [TestCase(":a", 2, 0, ":apple: ")] + [TestCase(":ap", 3, 0, ":apple: ")] + [TestCase(":a", 1, 0, ":apple: a")] + [TestCase("Test :", 6, 5, "Test :apple: ")] + [TestCase("Test :ap", 8, 5, "Test :apple: ")] + [TestCase("Test :apother stuff", 8, 5, "Test :apple: other stuff")] + public void ReturnsExpandedText(string text, int caretIndex, int completionOffset, string expected) + { + var textInput = Substitute.For(); + textInput.CaretIndex.Returns(caretIndex); + textInput.Text.Returns(text); + + var expandedText = textInput.GetExpandedText(":apple:", completionOffset); + Assert.AreEqual(expected, expandedText); + } } } } diff --git a/test/GitHub.UI.UnitTests/GitHub.UI.UnitTests.csproj b/test/GitHub.UI.UnitTests/GitHub.UI.UnitTests.csproj index 450a540013..a617e415be 100644 --- a/test/GitHub.UI.UnitTests/GitHub.UI.UnitTests.csproj +++ b/test/GitHub.UI.UnitTests/GitHub.UI.UnitTests.csproj @@ -2,15 +2,6 @@ net46 - - - - - - - - - @@ -31,19 +22,6 @@ - - - - - - - - - - - - - diff --git a/test/GitHub.UI.UnitTests/Helpers/AutoCompleteAdvisorTests.cs b/test/GitHub.UI.UnitTests/Helpers/AutoCompleteAdvisorTests.cs index d0f37c8b3e..a85fec36a0 100644 --- a/test/GitHub.UI.UnitTests/Helpers/AutoCompleteAdvisorTests.cs +++ b/test/GitHub.UI.UnitTests/Helpers/AutoCompleteAdvisorTests.cs @@ -3,205 +3,227 @@ using System.Reactive.Linq; using System.Threading.Tasks; using System.Windows.Media.Imaging; -using GitHub.Helpers; -using GitHub.UI; -using Moq; -using Xunit; +using GitHub.Models; +using GitHub.Services; +using NSubstitute; +using NUnit.Framework; -public class AutoCompleteAdvisorTests +namespace GitHub.UI.UnitTests.Helpers { - public class TheParseAutoCompletionTokenMethod + public class AutoCompleteAdvisorTests { - [Theory] - [InlineData(":", 1, "", 0)] - [InlineData(":po", 3, "po", 0)] - [InlineData(":po", 2, "p", 0)] - [InlineData(":po or no :po", 2, "p", 0)] - [InlineData(":po or no :po yo", 13, "po", 10)] - [InlineData("This is :poo", 12, "poo", 8)] - [InlineData("This is :poo or is it", 12, "poo", 8)] - [InlineData("This is\r\n:poo or is it", 13, "poo", 9)] - [InlineData("This is :poo or is it :zap:", 12, "poo", 8)] - public void ParsesWordOffsetAndType( - string text, - int caretPosition, - string expectedPrefix, - int expectedOffset) + public class TheParseAutoCompletionTokenMethod { - var token = AutoCompleteAdvisor.ParseAutoCompletionToken(text, caretPosition, ":"); + [TestCase(":", 1, "", 0)] + [TestCase(":po", 3, "po", 0)] + [TestCase(":po", 2, "p", 0)] + [TestCase(":po or no :po", 2, "p", 0)] + [TestCase(":po or no :po yo", 13, "po", 10)] + [TestCase("This is :poo", 12, "poo", 8)] + [TestCase("This is :poo or is it", 12, "poo", 8)] + [TestCase("This is\r\n:poo or is it", 13, "poo", 9)] + [TestCase("This is :poo or is it :zap:", 12, "poo", 8)] + public void ParsesWordOffsetAndType( + string text, + int caretPosition, + string expectedPrefix, + int expectedOffset) + { + var token = AutoCompleteAdvisor.ParseAutoCompletionToken(text, caretPosition, ":"); - Assert.Equal(expectedPrefix, token.SearchSearchPrefix); - Assert.Equal(expectedOffset, token.Offset); - } + Assert.AreEqual(expectedPrefix, token.SearchSearchPrefix); + Assert.AreEqual(expectedOffset, token.Offset); + } - [Theory] - [InlineData("", 0)] - [InlineData("foo bar", 0)] - [InlineData("This has no special stuff", 5)] - [InlineData("This has a : but caret is after the space after it", 13)] - public void ReturnsNullForTextWithoutAnyTriggerCharactersMatchingCaretIndex(string text, int caretPosition) - { - Assert.Null(AutoCompleteAdvisor.ParseAutoCompletionToken(text, caretPosition, ":")); + [TestCase("", 0)] + [TestCase("foo bar", 0)] + [TestCase("This has no special stuff", 5)] + [TestCase("This has a : but caret is after the space after it", 13)] + public void ReturnsNullForTextWithoutAnyTriggerCharactersMatchingCaretIndex(string text, int caretPosition) + { + Assert.Null(AutoCompleteAdvisor.ParseAutoCompletionToken(text, caretPosition, ":")); + } + + [TestCase("", 1)] + [TestCase("", -1)] + [TestCase("foo", 4)] + [TestCase("foo", -1)] + public void ThrowsExceptionWhenCaretIndexIsOutOfRangeOfText(string text, int caretIndex) + { + Assert.Throws( + () => AutoCompleteAdvisor.ParseAutoCompletionToken(text, caretIndex, ":")); + } } - [Theory] - [InlineData("", 1)] - [InlineData("", -1)] - [InlineData("foo", 4)] - [InlineData("foo", -1)] - public void ThrowsExceptionWhenCaretIndexIsOutOfRangeOfText(string text, int caretIndex) + public class TheGetAutoCompletionSuggestionsMethod { - Assert.Throws( - () => AutoCompleteAdvisor.ParseAutoCompletionToken(text, caretIndex, ":")); - } - } + [Test] + public async Task ReturnsResultsWhenOnlyTokenTyped() + { + var obs = Observable.Return(new BitmapImage()); - public class TheGetAutoCompletionSuggestionsMethod - { - [Fact] - public async Task ReturnsResultsWhenOnlyTokenTyped() - { - var obs = Observable.Return(new BitmapImage()); + var suggestions = new List + { + new AutoCompleteSuggestion("rainbow", obs, ":", ":"), + new AutoCompleteSuggestion("poop", obs, ":", ":"), + new AutoCompleteSuggestion("poop_scoop", obs, ":", ":") + }.ToObservable(); - var suggestions = new List - { - new AutoCompleteSuggestion("rainbow", obs, ":", ":"), - new AutoCompleteSuggestion("poop", obs, ":", ":"), - new AutoCompleteSuggestion("poop_scoop", obs, ":", ":") - }.ToObservable(); - var mentionsSource = Mock.Of(c => - c.GetSuggestions() == Observable.Empty() && c.Prefix == "@"); - var emojiSource = Mock.Of(c => - c.GetSuggestions() == suggestions && c.Prefix == ":"); - var advisor = new AutoCompleteAdvisor(new[] { mentionsSource, emojiSource }); - - var result = await advisor.GetAutoCompletionSuggestions(":", 1); - - Assert.Equal(0, result.Offset); - Assert.Equal(3, result.Suggestions.Count); - Assert.Equal("poop", result.Suggestions[0].Name); - Assert.Equal("poop_scoop", result.Suggestions[1].Name); - Assert.Equal("rainbow", result.Suggestions[2].Name); - } + var mentionsSource = Substitute.For(); + mentionsSource.GetSuggestions().Returns(Observable.Empty()); + mentionsSource.Prefix.Returns("@"); - [Fact] - public async Task ReturnsResultsWithNameMatchingToken() - { - var obs = Observable.Return(new BitmapImage()); + var emojiSource = Substitute.For(); + emojiSource.GetSuggestions().Returns(suggestions); + emojiSource.Prefix.Returns(":"); - var suggestions = new List - { - new AutoCompleteSuggestion("rainbow", obs, ":", ":"), - new AutoCompleteSuggestion("poop", obs, ":", ":"), - new AutoCompleteSuggestion("poop_scoop", obs, ":", ":") - }.ToObservable(); - var mentionsSource = Mock.Of(c => - c.GetSuggestions() == Observable.Empty() && c.Prefix == "@"); - var emojiSource = Mock.Of(c => - c.GetSuggestions() == suggestions && c.Prefix == ":"); - var advisor = new AutoCompleteAdvisor(new[] { mentionsSource, emojiSource }); - - var result = await advisor.GetAutoCompletionSuggestions("this is :poo", 12); - - Assert.Equal(8, result.Offset); - Assert.Equal(2, result.Suggestions.Count); - Assert.Equal("poop", result.Suggestions[0].Name); - Assert.Equal("poop_scoop", result.Suggestions[1].Name); - } + var advisor = new AutoCompleteAdvisor(new[] { mentionsSource, emojiSource }); - [Fact] - public async Task ReturnsResultsWithDescriptionMatchingToken() - { - var obs = Observable.Return(new BitmapImage()); + var result = await advisor.GetAutoCompletionSuggestions(":", 1); + + Assert.AreEqual(0, result.Offset); + Assert.AreEqual(3, result.Suggestions.Count); + Assert.AreEqual("poop", result.Suggestions[0].Name); + Assert.AreEqual("poop_scoop", result.Suggestions[1].Name); + Assert.AreEqual("rainbow", result.Suggestions[2].Name); + } - var suggestions = new List + [Test] + public async Task ReturnsResultsWithNameMatchingToken() { - new AutoCompleteSuggestion("rainbow", "John Doe", obs, "@", ""), - new AutoCompleteSuggestion("poop", "Alice Bob", obs, "@", ""), - new AutoCompleteSuggestion("poop_scoop", obs, "@", ""), - new AutoCompleteSuggestion("loop", "Jimmy Alice Cooper", obs, "@", ""), - }.ToObservable(); - var mentionsSource = Mock.Of(c => - c.GetSuggestions() == suggestions && c.Prefix == "@"); - var emojiSource = Mock.Of(c => - c.GetSuggestions() == Observable.Empty() && c.Prefix == ":"); - var advisor = new AutoCompleteAdvisor(new[] { mentionsSource, emojiSource }); - - var result = await advisor.GetAutoCompletionSuggestions("this is @alice", 12); - - Assert.Equal(8, result.Offset); - Assert.Equal(2, result.Suggestions.Count); - Assert.Equal("loop", result.Suggestions[0].Name); - Assert.Equal("poop", result.Suggestions[1].Name); - } + var obs = Observable.Return(new BitmapImage()); - [Fact] - public async Task ReturnsMentionsInCorrectOrder() - { - var obs = Observable.Return(new BitmapImage()); + var suggestions = new List + { + new AutoCompleteSuggestion("rainbow", obs, ":", ":"), + new AutoCompleteSuggestion("poop", obs, ":", ":"), + new AutoCompleteSuggestion("poop_scoop", obs, ":", ":") + }.ToObservable(); - var suggestions = new List + var mentionsSource = Substitute.For(); + mentionsSource.GetSuggestions().Returns(Observable.Empty()); + mentionsSource.Prefix.Returns("@"); + + var emojiSource = Substitute.For(); + emojiSource.GetSuggestions().Returns(suggestions); + emojiSource.Prefix.Returns(":"); + + var advisor = new AutoCompleteAdvisor(new[] { mentionsSource, emojiSource }); + + var result = await advisor.GetAutoCompletionSuggestions("this is :poo", 12); + + Assert.AreEqual(8, result.Offset); + Assert.AreEqual(2, result.Suggestions.Count); + Assert.AreEqual("poop", result.Suggestions[0].Name); + Assert.AreEqual("poop_scoop", result.Suggestions[1].Name); + } + + [Test] + public async Task ReturnsResultsWithDescriptionMatchingToken() { - // We need to have more than 10 matches to ensure we grab the most appropriate top ten - new AutoCompleteSuggestion("zztop1", "RainbowBright Doe", obs, "@", ""), - new AutoCompleteSuggestion("zztop2", "RainbowBright Doe", obs, "@", ""), - new AutoCompleteSuggestion("zztop3", "RainbowBright Doe", obs, "@", ""), - new AutoCompleteSuggestion("zztop4", "RainbowBright Doe", obs, "@", ""), - new AutoCompleteSuggestion("zztop5", "RainbowBright Doe", obs, "@", ""), - new AutoCompleteSuggestion("zztop6", "RainbowBright Doe", obs, "@", ""), - new AutoCompleteSuggestion("zztop7", "RainbowBright Doe", obs, "@", ""), - new AutoCompleteSuggestion("zztop8", "RainbowBright Doe", obs, "@", ""), - new AutoCompleteSuggestion("zztop9", "RainbowBright Doe", obs, "@", ""), - new AutoCompleteSuggestion("zztop10", "RainbowBright Doe", obs, "@", ""), - new AutoCompleteSuggestion("rainbowbright", "Jimmy Alice Cooper", obs, "@", ""), - new AutoCompleteSuggestion("apricot", "Bob Rainbow", obs, "@", ""), - new AutoCompleteSuggestion("rainbow", "John Doe", obs, "@", ""), - new AutoCompleteSuggestion("poop_scoop", obs, "@", ""), - new AutoCompleteSuggestion("zeke", "RainbowBright Doe", obs, "@", ""), - new AutoCompleteSuggestion("bill", "RainbowBright Doe", obs, "@", "") - }.ToObservable(); - var mentionsSource = Mock.Of(c => - c.GetSuggestions() == suggestions && c.Prefix == "@"); - var emojiSource = Mock.Of(c => - c.GetSuggestions() == Observable.Empty() && c.Prefix == ":"); - var advisor = new AutoCompleteAdvisor(new[] { mentionsSource, emojiSource }); - - var result = await advisor.GetAutoCompletionSuggestions("this is @rainbow sucka", 16); - - Assert.Equal("rainbow", result.Suggestions[0].Name); - Assert.Equal("rainbowbright", result.Suggestions[1].Name); - Assert.Equal("apricot", result.Suggestions[2].Name); - Assert.Equal("bill", result.Suggestions[3].Name); // Bill and Zeke have the same name - Assert.Equal("zeke", result.Suggestions[4].Name); // but the secondary sort is by login - } + var obs = Observable.Return(new BitmapImage()); - [Theory] - [InlineData("", 0)] - [InlineData("Foo bar baz", 0)] - [InlineData("Foo bar baz", 3)] - public async Task ReturnsEmptyAutoCompleteResult(string text, int caretIndex) - { - var autoCompleteSource = Mock.Of( - c => c.GetSuggestions() == Observable.Empty() && c.Prefix == ":"); + var suggestions = new List + { + new AutoCompleteSuggestion("rainbow", "John Doe", obs, "@", ""), + new AutoCompleteSuggestion("poop", "Alice Bob", obs, "@", ""), + new AutoCompleteSuggestion("poop_scoop", obs, "@", ""), + new AutoCompleteSuggestion("loop", "Jimmy Alice Cooper", obs, "@", ""), + }.ToObservable(); + + var mentionsSource = Substitute.For(); + mentionsSource.GetSuggestions().Returns(suggestions); + mentionsSource.Prefix.Returns("@"); + + var emojiSource = Substitute.For(); + emojiSource.GetSuggestions().Returns(Observable.Empty()); + emojiSource.Prefix.Returns(":"); + + var advisor = new AutoCompleteAdvisor(new[] { mentionsSource, emojiSource }); - var advisor = new AutoCompleteAdvisor(new[] {autoCompleteSource}); + var result = await advisor.GetAutoCompletionSuggestions("this is @alice", 12); + + Assert.AreEqual(8, result.Offset); + Assert.AreEqual(2, result.Suggestions.Count); + Assert.AreEqual("loop", result.Suggestions[0].Name); + Assert.AreEqual("poop", result.Suggestions[1].Name); + } + + [Test] + public async Task ReturnsMentionsInCorrectOrder() + { + var obs = Observable.Return(new BitmapImage()); + + var suggestions = new List + { + // We need to have more than 10 matches to ensure we grab the most appropriate top ten + new AutoCompleteSuggestion("zztop1", "RainbowBright Doe", obs, "@", ""), + new AutoCompleteSuggestion("zztop2", "RainbowBright Doe", obs, "@", ""), + new AutoCompleteSuggestion("zztop3", "RainbowBright Doe", obs, "@", ""), + new AutoCompleteSuggestion("zztop4", "RainbowBright Doe", obs, "@", ""), + new AutoCompleteSuggestion("zztop5", "RainbowBright Doe", obs, "@", ""), + new AutoCompleteSuggestion("zztop6", "RainbowBright Doe", obs, "@", ""), + new AutoCompleteSuggestion("zztop7", "RainbowBright Doe", obs, "@", ""), + new AutoCompleteSuggestion("zztop8", "RainbowBright Doe", obs, "@", ""), + new AutoCompleteSuggestion("zztop9", "RainbowBright Doe", obs, "@", ""), + new AutoCompleteSuggestion("zztop10", "RainbowBright Doe", obs, "@", ""), + new AutoCompleteSuggestion("rainbowbright", "Jimmy Alice Cooper", obs, "@", ""), + new AutoCompleteSuggestion("apricot", "Bob Rainbow", obs, "@", ""), + new AutoCompleteSuggestion("rainbow", "John Doe", obs, "@", ""), + new AutoCompleteSuggestion("poop_scoop", obs, "@", ""), + new AutoCompleteSuggestion("zeke", "RainbowBright Doe", obs, "@", ""), + new AutoCompleteSuggestion("bill", "RainbowBright Doe", obs, "@", "") + }.ToObservable(); + + var mentionsSource = Substitute.For(); + mentionsSource.GetSuggestions().Returns(suggestions); + mentionsSource.Prefix.Returns("@"); + + var emojiSource = Substitute.For(); + emojiSource.GetSuggestions().Returns(Observable.Empty()); + emojiSource.Prefix.Returns(":"); + + var advisor = new AutoCompleteAdvisor(new[] { mentionsSource, emojiSource }); + + var result = await advisor.GetAutoCompletionSuggestions("this is @rainbow sucka", 16); + + Assert.AreEqual("rainbow", result.Suggestions[0].Name); + Assert.AreEqual("rainbowbright", result.Suggestions[1].Name); + Assert.AreEqual("apricot", result.Suggestions[2].Name); + Assert.AreEqual("bill", result.Suggestions[3].Name); // Bill and Zeke have the same name + Assert.AreEqual("zeke", result.Suggestions[4].Name); // but the secondary sort is by login + } + + [Theory] + [TestCase("", 0)] + [TestCase("Foo bar baz", 0)] + [TestCase("Foo bar baz", 3)] + public async Task ReturnsEmptyAutoCompleteResult(string text, int caretIndex) + { + var autoCompleteSource = Substitute.For(); + autoCompleteSource.GetSuggestions().Returns(Observable.Empty()); + autoCompleteSource.Prefix.Returns(":"); + + var advisor = new AutoCompleteAdvisor(new[] {autoCompleteSource}); - var result = await advisor.GetAutoCompletionSuggestions(text, 0); + var result = await advisor.GetAutoCompletionSuggestions(text, 0); - Assert.Same(AutoCompleteResult.Empty, result); - } + Assert.AreSame(AutoCompleteResult.Empty, result); + } - [Fact] - public async Task ReturnsEmptyAutoCompleteResultWhenSourceThrowsException() - { - var autoCompleteSource = Mock.Of( - c => c.GetSuggestions() == Observable.Throw(new Exception("FAIL!")) - && c.Prefix == "@"); - var advisor = new AutoCompleteAdvisor(new[] { autoCompleteSource }); + [Test] + public async Task ReturnsEmptyAutoCompleteResultWhenSourceThrowsException() + { + var autoCompleteSource = Substitute.For(); + autoCompleteSource.GetSuggestions().Returns(Observable.Throw(new Exception("FAIL!"))); + autoCompleteSource.Prefix.Returns("@"); + + var advisor = new AutoCompleteAdvisor(new[] { autoCompleteSource }); - var result = await advisor.GetAutoCompletionSuggestions("@", 1); + var result = await advisor.GetAutoCompletionSuggestions("@", 1); - Assert.Same(AutoCompleteResult.Empty, result); + Assert.AreSame(AutoCompleteResult.Empty, result); + } } } } diff --git a/test/GitHub.UI.UnitTests/Helpers/AutoCompleteSourceTests.cs b/test/GitHub.UI.UnitTests/Helpers/AutoCompleteSourceTests.cs deleted file mode 100644 index f1a518c190..0000000000 --- a/test/GitHub.UI.UnitTests/Helpers/AutoCompleteSourceTests.cs +++ /dev/null @@ -1,89 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Reactive.Linq; -using System.Threading.Tasks; -using System.Windows.Media.Imaging; -using GitHub; -using GitHub.Cache; -using GitHub.Helpers; -using GitHub.Models; -using GitHub.UI; -using GitHub.ViewModels; -using Moq; -using Xunit; - -/// -/// Tests common to and . -/// Run the actual concrete test classes. -/// -public abstract class AutoCompleteSourceTests - where TAutoCompleteSource : IAutoCompleteSource - where TCacheInterface : class, IAutoCompleteSourceCache -{ - [Fact] - public async Task LocalRepositoryDoesNotSupportAutoComplete() - { - var container = new TestContainer(); - var localRepository = Mock.Of(); - container.Setup(vm => vm.SelectedRepository).Returns(localRepository); - var source = container.Get(); - - var suggestions = await source.GetSuggestions().ToList(); - - Assert.Empty(suggestions); - } - - [Fact] - public async Task ReturnsEmptyWhenSourceCacheThrows() - { - var container = new TestContainer(); - var gitHubRemote = Mock.Of(x => x.Address == HostAddress.Create("https://github.com/")); - var repository = Mock.Of(x => x.RepositoryHost == gitHubRemote); - container.Setup(vm => vm.SelectedRepository).Returns(repository); - container.Setup(c => c.RetrieveSuggestions(Args.RepositoryModel)) - .Returns(Observable.Throw>(new Exception("Shit happened!"))); - var source = container.Get(); - - var suggestions = await source.GetSuggestions().ToList(); - - Assert.Empty(suggestions); - } - - [Fact] - public async Task ReturnsResultForGitHubRepository() - { - var container = new TestContainer(); - var expectedAvatar = Mock.Of(); - var gitHubRepository = CreateRepository("https://github.com"); - container.Setup(vm => vm.SelectedRepository).Returns(gitHubRepository); - container.Setup(c => c.GetImage(new Uri("https://githubusercontent.com/a/shiftkey.png"))) - .Returns(Observable.Return(expectedAvatar)); - - var suggestions = new[] - { - new SuggestionItem("shiftkey", "Nice guy", new Uri("https://githubusercontent.com/a/shiftkey.png")) - }; - container.Setup(c => c.RetrieveSuggestions(gitHubRepository)) - .Returns(Observable.Return(suggestions)); - var source = container.Get(); - - var retrieved = await source.GetSuggestions().ToList(); - - Assert.NotEmpty(retrieved); - Assert.Equal("shiftkey", retrieved[0].Name); - Assert.Equal("Nice guy", retrieved[0].Description); - await AssertAvatar(expectedAvatar, retrieved[0]); - } - - protected IRepositoryModel CreateRepository(string hostAddress) - { - var gitHubRemote = Mock.Of(x => x.Address == HostAddress.Create(hostAddress)); - return Mock.Of(x => x.RepositoryHost == gitHubRemote); - } - - protected virtual async Task AssertAvatar(BitmapSource expected, AutoCompleteSuggestion suggestion) - { - var avatar = await suggestion.Image; - Assert.Same(expected, avatar); - } -} diff --git a/test/GitHub.UI.UnitTests/Helpers/IssuesAutoCompleteSourceTests.cs b/test/GitHub.UI.UnitTests/Helpers/IssuesAutoCompleteSourceTests.cs deleted file mode 100644 index 821daec048..0000000000 --- a/test/GitHub.UI.UnitTests/Helpers/IssuesAutoCompleteSourceTests.cs +++ /dev/null @@ -1,106 +0,0 @@ -using System; -using System.Linq; -using System.Reactive.Linq; -using System.Threading.Tasks; -using System.Windows.Media.Imaging; -using GitHub.Cache; -using GitHub.Helpers; -using GitHub.Models; -using GitHub.UI; -using GitHub.ViewModels; -using Xunit; - -/// -/// Tests of the . Test implementations are in -/// -/// -/// -/// THIS CLASS SHOULD ONLY CONTAIN TESTS SPECIFIC TO that deviate from the -/// behavior in common with all implementations. -/// -public class IssuesAutoCompleteSourceTests : AutoCompleteSourceTests -{ - [Fact] - public async Task ReturnsIssuesSortedByLastModified() - { - var container = new TestContainer(); - var gitHubRepository = CreateRepository("https://github.com"); - container.Setup(vm => vm.SelectedRepository).Returns(gitHubRepository); - - var suggestions = new[] - { - new SuggestionItem("100", "We should do this") { LastModifiedDate = DateTimeOffset.UtcNow.AddDays(-1) }, - new SuggestionItem("101", "This shit is broken") { LastModifiedDate = DateTimeOffset.UtcNow }, - new SuggestionItem("102", "What even?") { LastModifiedDate = DateTimeOffset.UtcNow.AddHours(-1) } - }; - container.Setup(c => c.RetrieveSuggestions(gitHubRepository)) - .Returns(Observable.Return(suggestions)); - var source = container.Get(); - - var retrieved = await source.GetSuggestions().ToList(); - - Assert.NotEmpty(retrieved); - retrieved = retrieved.OrderByDescending(r => r.GetSortRank("")).ToList(); - Assert.Equal("101", retrieved[0].Name); - Assert.Equal("102", retrieved[1].Name); - Assert.Equal("100", retrieved[2].Name); - } - - [Fact] - public async Task ReturnsIssuesFilteredByIssueNumber() - { - var container = new TestContainer(); - var gitHubRepository = CreateRepository("https://github.com"); - container.Setup(vm => vm.SelectedRepository).Returns(gitHubRepository); - - var suggestions = new[] - { - new SuggestionItem("#200", "We should do this") { LastModifiedDate = DateTimeOffset.UtcNow.AddDays(-1) }, - new SuggestionItem("#101", "This shit is broken") { LastModifiedDate = DateTimeOffset.UtcNow }, - new SuggestionItem("#210", "What even?") { LastModifiedDate = DateTimeOffset.UtcNow.AddHours(-1) } - }; - container.Setup(c => c.RetrieveSuggestions(gitHubRepository)) - .Returns(Observable.Return(suggestions)); - var source = container.Get(); - - var retrieved = await source.GetSuggestions().ToList(); - - Assert.NotEmpty(retrieved); - retrieved = retrieved.OrderByDescending(r => r.GetSortRank("2")).ToList(); - Assert.Equal("#200", retrieved[0].Name); - Assert.Equal("#210", retrieved[1].Name); - Assert.Equal("#101", retrieved[2].Name); - } - - [Fact] - public async Task ReturnsIssuesFilteredByText() - { - var container = new TestContainer(); - var gitHubRepository = CreateRepository("https://github.com"); - container.Setup(vm => vm.SelectedRepository).Returns(gitHubRepository); - - var suggestions = new[] - { - new SuggestionItem("#200", "We should do this") { LastModifiedDate = DateTimeOffset.UtcNow.AddDays(-1) }, - new SuggestionItem("#101", "This shit is broken") { LastModifiedDate = DateTimeOffset.UtcNow }, - new SuggestionItem("#210", "What even?") { LastModifiedDate = DateTimeOffset.UtcNow.AddHours(-1) } - }; - container.Setup(c => c.RetrieveSuggestions(gitHubRepository)) - .Returns(Observable.Return(suggestions)); - var source = container.Get(); - - var retrieved = await source.GetSuggestions().ToList(); - - Assert.NotEmpty(retrieved); - retrieved = retrieved.OrderByDescending(r => r.GetSortRank("shit")).ToList(); - Assert.Equal("#101", retrieved[0].Name); - Assert.Equal("#200", retrieved[1].Name); - Assert.Equal("#210", retrieved[2].Name); - } - - protected override Task AssertAvatar(BitmapSource expected, AutoCompleteSuggestion suggestion) - { - // Issues do not have images associated with them so we'll just ignore the avatar when asserting it. - return Task.FromResult(0); - } -} diff --git a/test/GitHub.UI.UnitTests/Helpers/MentionsAutoCompleteSourceTests.cs b/test/GitHub.UI.UnitTests/Helpers/MentionsAutoCompleteSourceTests.cs deleted file mode 100644 index 399f05c2b7..0000000000 --- a/test/GitHub.UI.UnitTests/Helpers/MentionsAutoCompleteSourceTests.cs +++ /dev/null @@ -1,13 +0,0 @@ -using GitHub.Helpers; - -/// -/// Tests of the . Test implementations are in -/// -/// -/// -/// THIS CLASS SHOULD ONLY CONTAIN TESTS SPECIFIC TO that deviate from the -/// behavior in common with all implementations. -/// -public class MentionsAutoCompleteSourceTests : AutoCompleteSourceTests -{ -}