User can receive search results while typing (#611)

* Cancelled networking when the cancel button is pressed

* Responded to comments: code formatting & cleanup

* Can now search while typing

Slight formatting changes

* Prevented search term from showing up in recent before search succeeds

* Show loading screen while typing

* Less complicated network cancellation

* Cleaned up groups

* Recently viewed repos (#631)

* Recently Viewed Repos saved in search store

* Styling changes

* Equatable changes and visual updates

* Visually update after selecting a repo

* Rebased

* Slowed down the debouncer a bit
This commit is contained in:
Hesham Salman
2017-10-22 00:11:12 -04:00
committed by Ryan Nystrom
parent 5bae4ba89d
commit a52e533b5f
13 changed files with 427 additions and 73 deletions

View File

@@ -8,8 +8,14 @@
import Foundation
struct RepositoryDetails {
struct RepositoryDetails: Codable, Equatable {
let owner: String
let name: String
let hasIssuesEnabled: Bool
}
func == (lhs: RepositoryDetails, rhs: RepositoryDetails) -> Bool {
return lhs.owner == rhs.owner &&
lhs.name == rhs.name &&
lhs.hasIssuesEnabled == rhs.hasIssuesEnabled
}

View File

@@ -12,7 +12,7 @@ import Apollo
extension GithubClient {
enum SearchResultType {
case error
case error(Error?)
case success(String?, [SearchRepoResult])
}
@@ -26,9 +26,11 @@ extension GithubClient {
let query = SearchReposQuery(search: query, before: before)
return fetch(query: query) { (result, error) in
guard errorIsNilOrCancelled(error), result?.errors == nil else {
ShowErrorStatusBar(graphQLErrors: result?.errors, networkError: error)
completion(.error)
guard error == nil, result?.errors == nil else {
if !isCancellationError(error) {
ShowErrorStatusBar(graphQLErrors: result?.errors, networkError: error)
}
completion(.error(error))
return
}
@@ -71,8 +73,3 @@ extension GithubClient {
}
}
private func errorIsNilOrCancelled(_ error: Error?) -> Bool {
guard let error = error else { return true }
return (error as NSError).code == -999
}

View File

@@ -0,0 +1,56 @@
//
// SearchQuery.swift
// Freetime
//
// Created by Hesham Salman on 10/21/17.
// Copyright © 2017 Ryan Nystrom. All rights reserved.
//
import Foundation
enum SearchQuery: Codable, Equatable {
case search(String), recentlyViewed(RepositoryDetails)
private enum CodingKeys: String, CodingKey {
case search
case recentlyViewed
}
enum SearchQueryCodingError: Error {
case decoding(String)
}
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
if let value = try? values.decode(String.self, forKey: .search) {
self = .search(value)
return
}
if let value = try? values.decode(RepositoryDetails.self, forKey: .recentlyViewed) {
self = .recentlyViewed(value)
return
}
throw SearchQueryCodingError.decoding("Unable to decode SearchQuery! \(dump(values))")
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .search(let text):
try container.encode(text, forKey: .search)
case .recentlyViewed(let repo):
try container.encode(repo, forKey: .recentlyViewed)
}
}
}
func == (lhs: SearchQuery, rhs: SearchQuery) -> Bool {
switch (lhs, rhs) {
case (let .search(lhsText), let .search(rhsText)):
return lhsText == rhsText
case (let .recentlyViewed(lhsRepo), let .recentlyViewed(rhsRepo)):
return lhsRepo == rhsRepo
default:
return false
}
}

View File

@@ -12,18 +12,29 @@ import SnapKit
final class SearchRecentCell: SwipeSelectableCell {
private let label = UILabel()
private let imageView = UIImageView()
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .white
imageView.contentMode = .scaleAspectFit
imageView.clipsToBounds = true
imageView.tintColor = Styles.Colors.Gray.light.color
contentView.addSubview(imageView)
imageView.snp.makeConstraints { make in
make.centerY.equalTo(contentView)
make.left.equalTo(Styles.Sizes.gutter)
make.width.equalTo(Styles.Sizes.icon.width)
}
label.textColor = Styles.Colors.Gray.dark.color
label.font = Styles.Fonts.body
contentView.addSubview(label)
label.snp.makeConstraints { make in
make.centerY.equalTo(contentView)
make.left.equalTo(Styles.Sizes.gutter)
make.left.equalTo(imageView.snp.right).offset(Styles.Sizes.gutter)
make.right.lessThanOrEqualTo(-Styles.Sizes.gutter)
}
@@ -41,8 +52,9 @@ final class SearchRecentCell: SwipeSelectableCell {
// MARK: Public API
func configure(_ text: String) {
label.text = text
func configure(viewModel: SearchRecentViewModel) {
imageView.image = viewModel.icon.withRenderingMode(.alwaysTemplate)
label.attributedText = viewModel.displayText
}
}

View File

@@ -11,13 +11,12 @@ import IGListKit
import SwipeCellKit
protocol SearchRecentSectionControllerDelegate: class {
func didSelect(recentSectionController: SearchRecentSectionController, text: String)
func didDelete(recentSectionController: SearchRecentSectionController, text: String)
func didSelect(recentSectionController: SearchRecentSectionController, viewModel: SearchRecentViewModel)
func didDelete(recentSectionController: SearchRecentSectionController, viewModel: SearchRecentViewModel)
}
// bridge to NSString for NSObject conformance
final class SearchRecentSectionController: ListGenericSectionController<NSString>,
SwipeCollectionViewCellDelegate {
final class SearchRecentSectionController: ListGenericSectionController<SearchRecentViewModel>, SwipeCollectionViewCellDelegate {
weak var delegate: SearchRecentSectionControllerDelegate? = nil
lazy var recentStore = SearchRecentStore()
@@ -36,13 +35,13 @@ SwipeCollectionViewCellDelegate {
guard let cell = collectionContext?.dequeueReusableCell(of: SearchRecentCell.self, for: self, at: index) as? SearchRecentCell
else { fatalError("Missing context or wrong cell type") }
cell.delegate = self
cell.configure(text)
cell.configure(viewModel: searchViewModel)
return cell
}
override func didSelectItem(at index: Int) {
collectionContext?.deselectItem(at: index, sectionController: self, animated: true)
delegate?.didSelect(recentSectionController: self, text: text)
delegate?.didSelect(recentSectionController: self, viewModel: searchViewModel)
}
// MARK: SwipeCollectionViewCellDelegate
@@ -52,7 +51,7 @@ SwipeCollectionViewCellDelegate {
let action = SwipeAction(style: .destructive, title: Constants.Strings.delete) { [weak self] _, _ in
guard let strongSelf = self, let object = strongSelf.object else { return }
strongSelf.delegate?.didDelete(recentSectionController: strongSelf, text: object as String)
strongSelf.delegate?.didDelete(recentSectionController: strongSelf, viewModel: object)
}
action.image = #imageLiteral(resourceName: "trashcan").withRenderingMode(.alwaysTemplate)
action.backgroundColor = Styles.Colors.Red.medium.color
@@ -70,8 +69,8 @@ SwipeCollectionViewCellDelegate {
// MARK: Private API
var text: String {
return (object ?? "") as String
var searchViewModel: SearchRecentViewModel {
return object ?? SearchRecentViewModel(query: .search(""))
}
}

View File

@@ -15,48 +15,63 @@ final class SearchRecentStore {
}
private let defaults = UserDefaults.standard
private var _recents: NSMutableOrderedSet
private var _recents: [SearchQuery]
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
init() {
if let arr = defaults.object(forKey: Keys.results) as? [String] {
_recents = NSMutableOrderedSet(array: arr)
if let data = defaults.object(forKey: Keys.results) as? Data,
let array = try? decoder.decode([SearchQuery].self, from: data) {
_recents = array
} else {
_recents = NSMutableOrderedSet()
_recents = []
}
}
// MARK: Public API
func add(recent: String) {
_recents.remove(recent)
_recents.insert(recent, at: 0)
func add(query: SearchQuery) {
remove(query: query)
_recents.insert(query, at: 0)
// keep recents trimmed
while _recents.count > 15 {
_recents.removeObject(at: _recents.count - 1)
_recents.removeLast()
}
save()
}
func remove(recent: String) {
_recents.remove(recent)
func remove(query: SearchQuery) {
guard let offset = _recents.index(of: query) else { return }
let index = _recents.startIndex.distance(to: offset)
_recents.remove(at: index)
save()
}
func clear() {
_recents.removeAllObjects()
_recents.removeAll()
save()
}
var recents: [String] {
return _recents.array as? [String] ?? []
var recents: [SearchQuery] {
return _recents
}
func removeLast() {
guard !recents.isEmpty else { return }
_recents.removeLast()
save()
}
// MARK: Private API
func save() {
defaults.set(_recents.array, forKey: Keys.results)
guard let data = try? encoder.encode(recents) else {
return
}
defaults.set(data, forKey: Keys.results)
}
}

View File

@@ -0,0 +1,77 @@
//
// SearchRecentViewModel.swift
// Freetime
//
// Created by Hesham Salman on 10/21/17.
// Copyright © 2017 Ryan Nystrom. All rights reserved.
//
import Foundation
import IGListKit
private enum Keys {
static let search = "search"
static let viewed = "viewed"
}
final class SearchRecentViewModel: NSObject, ListDiffable {
let query: SearchQuery
init(query: SearchQuery) {
self.query = query
}
var displayText: NSAttributedString {
switch query {
case .search(let text):
return NSAttributedString(string: text, attributes: standardAttributes)
case .recentlyViewed(let repoDetails):
let text = NSMutableAttributedString(string: repoDetails.owner + "/", attributes: standardAttributes)
text.append(NSAttributedString(string: repoDetails.name, attributes: boldAttributes))
return text
}
}
var icon: UIImage {
switch query {
case .search:
return #imageLiteral(resourceName: "search")
case .recentlyViewed:
return #imageLiteral(resourceName: "repo")
}
}
// MARK: ListDiffable
func diffIdentifier() -> NSObjectProtocol {
var identifier: String
switch query {
case .recentlyViewed:
identifier = Keys.viewed
case .search:
identifier = Keys.search
}
return (identifier + displayText.string) as NSObjectProtocol
}
func isEqual(toDiffableObject object: ListDiffable?) -> Bool {
return true
}
// MARK: Private API
private var standardAttributes: [NSAttributedStringKey: Any] {
return [
.font: Styles.Fonts.body,
.foregroundColor: Styles.Colors.Gray.dark.color
]
}
private var boldAttributes: [NSAttributedStringKey: Any] {
return [
.font: Styles.Fonts.bodyBold,
.foregroundColor: Styles.Colors.Gray.dark.color
]
}
}

View File

@@ -9,44 +9,45 @@
import IGListKit
protocol SearchResultSectionControllerDelegate: class {
func didSelect(sectionController: SearchResultSectionController)
func didSelect(sectionController: SearchResultSectionController, repo: RepositoryDetails)
}
final class SearchResultSectionController: ListGenericSectionController<SearchRepoResult> {
private weak var delegate: SearchResultSectionControllerDelegate? = nil
private let client: GithubClient
init(client: GithubClient, delegate: SearchResultSectionControllerDelegate) {
self.client = client
self.delegate = delegate
super.init()
}
override func sizeForItem(at index: Int) -> CGSize {
guard let width = collectionContext?.containerSize.width else { fatalError("Missing context") }
return CGSize(width: width, height: Styles.Sizes.tableCellHeight + Styles.Sizes.rowSpacing * 2)
}
override func cellForItem(at index: Int) -> UICollectionViewCell {
guard let cell = collectionContext?.dequeueReusableCell(of: SearchRepoResultCell.self, for: self, at: index) as? SearchRepoResultCell,
let object = object else {
fatalError("Missing context, object, or cell is wrong type")
}
cell.configure(result: object)
return cell
}
override func didSelectItem(at index: Int) {
guard let object = object else { return }
delegate?.didSelect(sectionController: self)
let repo = RepositoryDetails(owner: object.owner, name: object.name, hasIssuesEnabled: object.hasIssuesEnabled)
delegate?.didSelect(sectionController: self, repo: repo)
let repoViewController = RepositoryViewController(client: client, repo: repo)
let navigation = UINavigationController(rootViewController: repoViewController)
viewController?.showDetailViewController(navigation, sender: nil)
}
}

View File

@@ -24,14 +24,21 @@ SearchResultSectionControllerDelegate {
private let noResultsKey = "com.freetime.SearchViewController.no-results-key" as ListDiffable
private let recentHeaderKey = "com.freetime.SearchViewController.recent-header-key" as ListDiffable
private let recentStore = SearchRecentStore()
private let debouncer = Debouncer()
enum State {
case idle
case loading(Cancellable)
case loading(Cancellable?)
case results([ListDiffable])
case error
}
private var state: State = .idle
private var state: State = .idle {
willSet {
if case let .loading(request) = state {
request?.cancel()
}
}
}
private let searchBar = UISearchBar()
private lazy var adapter: ListAdapter = { ListAdapter(updater: ListAdapterUpdater(), viewController: self) }()
@@ -91,6 +98,8 @@ SearchResultSectionControllerDelegate {
private func handle(resultType: GithubClient.SearchResultType, animated: Bool) {
switch resultType {
case let .error(error) where isCancellationError(error):
self.state = .loading(nil)
case .error:
self.state = .error
case .success(_, let results):
@@ -100,15 +109,15 @@ SearchResultSectionControllerDelegate {
}
func search(term: String) {
recentStore.add(recent: term)
update(animated: false)
recentStore.add(query: .search(term))
let request = client.search(query: term, containerWidth: view.bounds.width) { [weak self] resultType in
guard let state = self?.state, case .loading = state else { return }
self?.handle(resultType: resultType, animated: true)
}
state = .loading(request)
update(animated: false)
}
// MARK: ListAdapterDataSource
@@ -116,7 +125,7 @@ SearchResultSectionControllerDelegate {
func objects(for listAdapter: ListAdapter) -> [ListDiffable] {
switch state {
case .idle:
var recents = recentStore.recents as [ListDiffable]
var recents: [ListDiffable] = recentStore.recents.flatMap { SearchRecentViewModel(query: $0) }
if recents.count > 0 {
recents.insert(recentHeaderKey, at: 0)
}
@@ -142,7 +151,7 @@ SearchResultSectionControllerDelegate {
return SearchRecentHeaderSectionController(delegate: self)
} else if object is SearchRepoResult {
return SearchResultSectionController(client: client, delegate: self)
} else if object is String {
} else if object is SearchRecentViewModel {
return SearchRecentSectionController(delegate: self)
}
@@ -169,11 +178,17 @@ SearchResultSectionControllerDelegate {
// MARK: UISearchBarDelegate
func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) {
guard let term = searchBar.text?.trimmingCharacters(in: .whitespacesAndNewlines),
term.isEmpty else { return }
state = .idle
update(animated: false)
guard let term = searchTerm(for: searchBar.text) else {
state = .idle
update(animated: false)
return
}
if case .loading = state {
recentStore.removeLast()
}
debouncer.action = { [weak self] in self?.search(term: term) }
}
func searchBarTextDidBeginEditing(_ searchBar: UISearchBar) {
@@ -182,16 +197,12 @@ SearchResultSectionControllerDelegate {
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
searchBar.resignFirstResponder()
guard let term = searchBar.text?.trimmingCharacters(in: .whitespacesAndNewlines),
!term.isEmpty else { return }
guard let term = searchTerm(for: searchBar.text) else { return }
search(term: term)
}
func searchBarCancelButtonClicked(_ searchBar: UISearchBar) {
if case let .loading(request) = state {
request.cancel()
}
searchBar.setShowsCancelButton(false, animated: true)
searchBar.text = ""
searchBar.resignFirstResponder()
@@ -209,12 +220,31 @@ SearchResultSectionControllerDelegate {
// MARK: SearchRecentSectionControllerDelegate
func didSelect(recentSectionController: SearchRecentSectionController, text: String) {
func didSelect(recentSectionController: SearchRecentSectionController, viewModel: SearchRecentViewModel) {
searchBar.resignFirstResponder()
if case let .search(text) = viewModel.query {
didSelectSearch(text: text)
} else if case let .recentlyViewed(repo) = viewModel.query {
didSelectRepo(repo: repo)
}
}
private func didSelectSearch(text: String) {
searchBar.setShowsCancelButton(true, animated: false)
searchBar.text = text
search(term: text)
}
private func didSelectRepo(repo: RepositoryDetails) {
recentStore.add(query: .recentlyViewed(repo))
update(animated: false)
let repoViewController = RepositoryViewController(client: client, repo: repo)
let navigation = UINavigationController(rootViewController: repoViewController)
showDetailViewController(navigation, sender: nil)
}
// MARK: SearchRecentHeaderSectionControllerDelegate
func didTapClear(sectionController: SearchRecentHeaderSectionController) {
@@ -248,13 +278,22 @@ SearchResultSectionControllerDelegate {
// MARK: SearchResultSectionControllerDelegate
func didSelect(sectionController: SearchResultSectionController) {
func didSelect(sectionController: SearchResultSectionController, repo: RepositoryDetails) {
recentStore.add(query: .recentlyViewed(repo))
update(animated: false)
searchBar.resignFirstResponder()
}
func didDelete(recentSectionController: SearchRecentSectionController, text: String) {
recentStore.remove(recent: text)
func didDelete(recentSectionController: SearchRecentSectionController, viewModel: SearchRecentViewModel) {
recentStore.remove(query: viewModel.query)
update(animated: true)
}
// MARK: Private API
private func searchTerm(for searchBarText: String?) -> String? {
guard let term = searchBarText?.trimmingCharacters(in: .whitespacesAndNewlines),
!term.isEmpty else { return nil }
return term
}
}

View File

@@ -0,0 +1,44 @@
//
// Debouncer.swift
// Freetime
//
// Created by Hesham Salman on 10/18/17.
// Copyright © 2017 Ryan Nystrom. All rights reserved.
//
import Foundation
class Debouncer {
private let delay: Double
private weak var timer: Timer?
var action: (() -> Void)? {
didSet {
debounce()
}
}
init(delay: Double = 0.35) {
self.delay = delay
}
convenience init(delay: Double = 0.35, action: @escaping (() -> Void)) {
self.init(delay: delay)
self.action = action
}
private func debounce() {
timer?.invalidate()
timer = Timer.scheduledTimer(
timeInterval: delay,
target: self,
selector: #selector(Debouncer.executeAction(_:)),
userInfo: nil,
repeats: false
)
}
@objc private func executeAction(_ sender: Timer) {
action?()
}
}

View File

@@ -0,0 +1,14 @@
//
// IsCancellationError.swift
// Freetime
//
// Created by Hesham Salman on 10/19/17.
// Copyright © 2017 Ryan Nystrom. All rights reserved.
//
import Foundation
func isCancellationError(_ error: Error?) -> Bool {
guard let error = error else { return false }
return (error as NSError).code == -999
}

View File

@@ -368,10 +368,16 @@
98B5A0861F6D0FFE000617D6 /* UINavigationController+Replace.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98B5A0851F6D0FFE000617D6 /* UINavigationController+Replace.swift */; };
98F0A0431F27BC4B0062A2CA /* emoji.json in Resources */ = {isa = PBXBuildFile; fileRef = 98F0A0421F27BC4B0062A2CA /* emoji.json */; };
C2FAAA639721846A5B477E5C /* Pods_FreetimeTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F2F33C5360D6CF79F44FFF42 /* Pods_FreetimeTests.framework */; };
DC3238991F9C3213007DD924 /* SearchRecentStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC3238981F9C3213007DD924 /* SearchRecentStoreTests.swift */; };
DC60C6C81F9803BF00241271 /* SearchRecentStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC60C6C71F9803BF00241271 /* SearchRecentStoreTests.swift */; };
DC60C6CB1F98341900241271 /* SearchEmptyViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC60C6CA1F98341900241271 /* SearchEmptyViewTests.swift */; };
DC60C6CE1F98346400241271 /* MockSearchEmptyViewDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC60C6CD1F98346400241271 /* MockSearchEmptyViewDelegate.swift */; };
DC60C6D31F983BB900241271 /* SignatureTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC60C6D21F983BB900241271 /* SignatureTests.swift */; };
DC60C6D51F983DF800241271 /* IssueLabelCellTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC60C6D41F983DF800241271 /* IssueLabelCellTests.swift */; };
DC60C6DC1F99414E00241271 /* IsCancellationError.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC60C6DB1F99414E00241271 /* IsCancellationError.swift */; };
DC7857101F97F546009BADDA /* Debouncer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC78570F1F97F546009BADDA /* Debouncer.swift */; };
DC3238911F9B9E1A007DD924 /* SearchRecentViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC3238901F9B9E1A007DD924 /* SearchRecentViewModel.swift */; };
DC3238931F9BA29D007DD924 /* SearchQuery.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC3238921F9BA29D007DD924 /* SearchQuery.swift */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -723,10 +729,16 @@
98F0A0421F27BC4B0062A2CA /* emoji.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = emoji.json; sourceTree = "<group>"; };
B9D4562D9138A112D9EDD9DD /* Pods-Freetime.testflight.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Freetime.testflight.xcconfig"; path = "Pods/Target Support Files/Pods-Freetime/Pods-Freetime.testflight.xcconfig"; sourceTree = "<group>"; };
CD6851CDA1BB3DB6F779C307 /* Pods-FreetimeTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FreetimeTests.release.xcconfig"; path = "Pods/Target Support Files/Pods-FreetimeTests/Pods-FreetimeTests.release.xcconfig"; sourceTree = "<group>"; };
DC3238981F9C3213007DD924 /* SearchRecentStoreTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SearchRecentStoreTests.swift; sourceTree = "<group>"; };
DC60C6C71F9803BF00241271 /* SearchRecentStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRecentStoreTests.swift; sourceTree = "<group>"; };
DC60C6CA1F98341900241271 /* SearchEmptyViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchEmptyViewTests.swift; sourceTree = "<group>"; };
DC60C6CD1F98346400241271 /* MockSearchEmptyViewDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockSearchEmptyViewDelegate.swift; sourceTree = "<group>"; };
DC60C6D21F983BB900241271 /* SignatureTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignatureTests.swift; sourceTree = "<group>"; };
DC60C6D41F983DF800241271 /* IssueLabelCellTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssueLabelCellTests.swift; sourceTree = "<group>"; };
DC60C6DB1F99414E00241271 /* IsCancellationError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IsCancellationError.swift; sourceTree = "<group>"; };
DC78570F1F97F546009BADDA /* Debouncer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Debouncer.swift; sourceTree = "<group>"; };
DC3238901F9B9E1A007DD924 /* SearchRecentViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchRecentViewModel.swift; sourceTree = "<group>"; };
DC3238921F9BA29D007DD924 /* SearchQuery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchQuery.swift; sourceTree = "<group>"; };
EA08BE0B8263E4898B1DF86B /* Pods-Freetime.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Freetime.release.xcconfig"; path = "Pods/Target Support Files/Pods-Freetime/Pods-Freetime.release.xcconfig"; sourceTree = "<group>"; };
F2F33C5360D6CF79F44FFF42 /* Pods_FreetimeTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_FreetimeTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
@@ -1148,6 +1160,7 @@
297AE86D1EC0D5C200B44A1F /* Resources */,
296CD8231F014130001190B9 /* Sample */,
2930F2701F894AA10082BA26 /* Settings.bundle */,
DC3238971F9C3208007DD924 /* Recovered References */,
);
sourceTree = "<group>";
};
@@ -1163,21 +1176,21 @@
297AE84B1EC0D58A00B44A1F /* FreetimeTests */ = {
isa = PBXGroup;
children = (
DC60C6CF1F9836C500241271 /* Search Tests */,
DC60C6CC1F98344B00241271 /* Mocks */,
297AE84C1EC0D58A00B44A1F /* DateDisplayTests.swift */,
2981A8A61EFEBEF900E25EF1 /* EmojiTests.swift */,
296B4E331F7C80B800C16887 /* GraphQLIDDecodeTests.swift */,
297AE84E1EC0D58A00B44A1F /* Info.plist */,
DC60C6D41F983DF800241271 /* IssueLabelCellTests.swift */,
29A476B11ED24D99005D0953 /* IssueTests.swift */,
29C2950D1EC7B43B00D46CD2 /* ListKitTestCase.swift */,
29C295091EC7AFA500D46CD2 /* ListTestKit.swift */,
295F52A81EF1B9E2000B53CF /* MMMarkdownASTTests.swift */,
DC60C6CC1F98344B00241271 /* Mocks */,
49D028FF1F91D90C00E39094 /* ReactionTests.swift */,
DC60C6CF1F9836C500241271 /* Search Tests */,
DC60C6D21F983BB900241271 /* SignatureTests.swift */,
29EDFE7B1F65C580005BCCEB /* SplitViewTests.swift */,
295F52A61EF1B9D2000B53CF /* Test.md */,
DC60C6D21F983BB900241271 /* SignatureTests.swift */,
DC60C6D41F983DF800241271 /* IssueLabelCellTests.swift */,
);
path = FreetimeTests;
sourceTree = "<group>";
@@ -1283,6 +1296,7 @@
292CD3CF1F0DBB5C00D3D57B /* WebviewCellHeightCache.swift */,
2930F2721F8A27750082BA26 /* WidthCache.swift */,
3E79A2FE1F8A7DA700E1126B /* ShortcutHandler.swift */,
DC78570F1F97F546009BADDA /* Debouncer.swift */,
);
path = Systems;
sourceTree = "<group>";
@@ -1510,6 +1524,8 @@
986B87241F2B990A00AAB55C /* SearchRepoResultCell.swift */,
986B87221F2B98AD00AAB55C /* SearchResultSectionController.swift */,
986B871C1F2B8FCD00AAB55C /* SearchViewController.swift */,
DC3238901F9B9E1A007DD924 /* SearchRecentViewModel.swift */,
DC3238921F9BA29D007DD924 /* SearchQuery.swift */,
);
path = Search;
sourceTree = "<group>";
@@ -1550,6 +1566,7 @@
754488B01F7ADF8D0032D08C /* UIAlertController+Action.swift */,
98835BCD1F1965E2005BA24F /* UIDevice+Model.swift */,
98B5A0851F6D0FFE000617D6 /* UINavigationController+Replace.swift */,
DC60C6DB1F99414E00241271 /* IsCancellationError.swift */,
);
path = Utility;
sourceTree = "<group>";
@@ -1589,6 +1606,14 @@
name = Pods;
sourceTree = "<group>";
};
DC3238971F9C3208007DD924 /* Recovered References */ = {
isa = PBXGroup;
children = (
DC60C6C71F9803BF00241271 /* SearchRecentStoreTests.swift */,
);
name = "Recovered References";
sourceTree = "<group>";
};
DC60C6CC1F98344B00241271 /* Mocks */ = {
isa = PBXGroup;
children = (
@@ -1601,6 +1626,7 @@
isa = PBXGroup;
children = (
DC60C6CA1F98341900241271 /* SearchEmptyViewTests.swift */,
DC3238981F9C3213007DD924 /* SearchRecentStoreTests.swift */,
);
path = "Search Tests";
sourceTree = "<group>";
@@ -2001,6 +2027,7 @@
292FCAF61EDFCC510026635E /* IssueCommentCodeBlockCell.swift in Sources */,
292FCAF71EDFCC510026635E /* IssueCommentCodeBlockModel.swift in Sources */,
292FCAF91EDFCC510026635E /* IssueCommentDetailCell.swift in Sources */,
DC3238931F9BA29D007DD924 /* SearchQuery.swift in Sources */,
7BBFEE5B1F8A8A0400C68E47 /* SearchBarSectionController.swift in Sources */,
292FCAFA1EDFCC510026635E /* IssueCommentDetailsViewModel.swift in Sources */,
2949674E1EF9719300B1CF1A /* IssueCommentHrCell.swift in Sources */,
@@ -2017,6 +2044,7 @@
295840D81EEA0686007723C6 /* IssueCommentQuoteModel.swift in Sources */,
292FCB071EDFCC510026635E /* IssueCommentReactionCell.swift in Sources */,
292FCB1F1EDFCD750026635E /* IssueCommentReactionViewModel.swift in Sources */,
DC3238911F9B9E1A007DD924 /* SearchRecentViewModel.swift in Sources */,
292FCAFF1EDFCC510026635E /* IssueCommentSectionController.swift in Sources */,
292FCB081EDFCC510026635E /* IssueCommentSummaryCell.swift in Sources */,
292FCB091EDFCC510026635E /* IssueCommentSummaryModel.swift in Sources */,
@@ -2170,8 +2198,10 @@
29DA1E771F5DA27D0050C64B /* SearchEmptyView.swift in Sources */,
29DA1E791F5DEE8F0050C64B /* SearchLoadingView.swift in Sources */,
986B872D1F2C846700AAB55C /* SearchNoResultsCell.swift in Sources */,
DC60C6DC1F99414E00241271 /* IsCancellationError.swift in Sources */,
986B872B1F2C842000AAB55C /* SearchNoResultsSectionController.swift in Sources */,
29DA1E841F5E26310050C64B /* SearchRecentCell.swift in Sources */,
DC7857101F97F546009BADDA /* Debouncer.swift in Sources */,
29DA1E881F5E2B8A0050C64B /* SearchRecentHeaderCell.swift in Sources */,
29DA1E8A1F5E2DEC0050C64B /* SearchRecentHeaderSectionController.swift in Sources */,
29AF1E841F8AAB4A0008A0EF /* UITextView+GitHawk.swift in Sources */,
@@ -2280,6 +2310,8 @@
DC60C6D51F983DF800241271 /* IssueLabelCellTests.swift in Sources */,
294B11251F7B40CA00E04F2D /* IssueDetailsModel.swift in Sources */,
293A45941F296D5B00DD1006 /* CollapsibleCell.swift in Sources */,
DC60C6C81F9803BF00241271 /* SearchRecentStoreTests.swift in Sources */,
DC3238991F9C3213007DD924 /* SearchRecentStoreTests.swift in Sources */,
293A45961F298C9500DD1006 /* UIColor+Hex.swift in Sources */,
293A459D1F298EDB00DD1006 /* IssueCommentQuoteCell.swift in Sources */,
293A458E1F296CF300DD1006 /* MMElement+Image.swift in Sources */,

View File

@@ -0,0 +1,62 @@
//
// SearchRecentStoreTests.swift
// FreetimeTests
//
// Created by Hesham Salman on 10/18/17.
// Copyright © 2017 Ryan Nystrom. All rights reserved.
//
import Foundation
import XCTest
@testable import Freetime
class SearchRecentStoreTests: XCTestCase {
var store: SearchRecentStore!
override func setUp() {
super.setUp()
store = SearchRecentStore()
store.clear() // in case you've used the app in-simulator before running these tests
}
override func tearDown() {
super.tearDown()
store?.clear()
}
func test_add_canReorderObjects() {
store.add(recent: "samurai jack")
store.add(recent: "aku")
store.add(recent: "samurai jack")
let expected = "samurai jack"
let actual = store.recents.first
XCTAssertEqual(expected, actual)
}
func test_clear_removesAllObjects() {
store.add(recent: "cat")
store.add(recent: "bug")
store.clear()
XCTAssertTrue(store.recents.isEmpty)
}
func test_removesLast_doesntCrashOnEmpty() {
XCTAssertTrue(store.recents.isEmpty)
store.removeLast()
}
func test_removesLast_removesLastObject() {
store.add(recent: "finn")
store.add(recent: "jake")
store.removeLast()
let expectedCount = 1
let actualCount = store.recents.count
XCTAssertEqual(expectedCount, actualCount)
}
}