diff --git a/Shared/Data/ArticleUtilities.swift b/Shared/Data/ArticleUtilities.swift index bb46b045f..b822ab706 100644 --- a/Shared/Data/ArticleUtilities.swift +++ b/Shared/Data/ArticleUtilities.swift @@ -103,8 +103,12 @@ extension Article: SortableArticle { return logicalDatePublished } - var sortableID: String { + var sortableArticleID: String { return articleID } + var sortableFeedID: String { + return feedID + } + } diff --git a/Shared/Timeline/ArticleSorter.swift b/Shared/Timeline/ArticleSorter.swift index d1b8814ce..9196ee765 100644 --- a/Shared/Timeline/ArticleSorter.swift +++ b/Shared/Timeline/ArticleSorter.swift @@ -12,46 +12,58 @@ import Foundation protocol SortableArticle { var sortableName: String { get } var sortableDate: Date { get } - var sortableID: String { get } + var sortableArticleID: String { get } + var sortableFeedID: String { get } } struct ArticleSorter { - + static func sortedByDate(articles: [T], sortDirection: ComparisonResult, groupByFeed: Bool) -> [T] { - let articles = articles.sorted { (article1, article2) -> Bool in - if groupByFeed { - let feedName1 = article1.sortableName - let feedName2 = article2.sortableName - - let comparison = feedName1.caseInsensitiveCompare(feedName2) - switch comparison { - case .orderedSame: - return isSortedByDate(article1, article2, sortDirection: sortDirection) - case .orderedAscending, .orderedDescending: - return comparison == .orderedAscending - } - } else { - return isSortedByDate(article1, article2, sortDirection: sortDirection) - } + if groupByFeed { + return sortedByFeedName(articles: articles, sortByDateDirection: sortDirection) + } else { + return sortedByDate(articles: articles, sortDirection: sortDirection) } - - return articles } // MARK: - + + private static func sortedByFeedName(articles: [T], + sortByDateDirection: ComparisonResult) -> [T] { + // Group articles by feed - feed ID is used to differentiate between + // two feeds that have the same name + var groupedArticles = Dictionary(grouping: articles) { "\($0.sortableName.lowercased())-\($0.sortableFeedID)" } + + // Sort the articles within each group + for tuple in groupedArticles { + groupedArticles[tuple.key] = sortedByDate(articles: tuple.value, + sortDirection: sortByDateDirection) + } + + // Flatten the articles dictionary back into an array sorted by feed name + var sortedArticles: [T] = [] + for feedName in groupedArticles.keys.sorted() { + sortedArticles.append(contentsOf: groupedArticles[feedName] ?? []) + } + + return sortedArticles + } - private static func isSortedByDate(_ lhs: SortableArticle, - _ rhs: SortableArticle, - sortDirection: ComparisonResult) -> Bool { - if lhs.sortableDate == rhs.sortableDate { - return lhs.sortableID < rhs.sortableID + private static func sortedByDate(articles: [T], + sortDirection: ComparisonResult) -> [T] { + let articles = articles.sorted { (article1, article2) -> Bool in + if article1.sortableDate == article2.sortableDate { + return article1.sortableArticleID < article2.sortableArticleID + } + if sortDirection == .orderedDescending { + return article1.sortableDate > article2.sortableDate + } + + return article1.sortableDate < article2.sortableDate } - if sortDirection == .orderedDescending { - return lhs.sortableDate > rhs.sortableDate - } - return lhs.sortableDate < rhs.sortableDate + return articles } } diff --git a/Tests/NetNewsWireTests/ArticleArrayTests.swift b/Tests/NetNewsWireTests/ArticleArrayTests.swift index b5ed8d3d0..dd78ace25 100644 --- a/Tests/NetNewsWireTests/ArticleArrayTests.swift +++ b/Tests/NetNewsWireTests/ArticleArrayTests.swift @@ -14,96 +14,227 @@ import XCTest class ArticleArrayTests: XCTestCase { + // MARK: sortByDate ascending tests + func testSortByDateAscending() { let now = Date() - // Test data includes a mixture of articles in the past and future, as well as articles with the same date - let article1 = TestArticle(sortableName: "Phil's Feed", sortableDate: now, sortableID: "456") - let article2 = TestArticle(sortableName: "Matt's Feed", sortableDate: now, sortableID: "789") - let article3 = TestArticle(sortableName: "Sally's Feed", sortableDate: now, sortableID: "123") - let article4 = TestArticle(sortableName: "Susie's Feed", sortableDate: Date(timeInterval: -60.0, since: now), sortableID: "345") - let article5 = TestArticle(sortableName: "Paul's Feed", sortableDate: Date(timeInterval: -120.0, since: now), sortableID: "567") - let article6 = TestArticle(sortableName: "phil's Feed", sortableDate: Date(timeInterval: 60.0, since: now), sortableID: "567") + let article1 = TestArticle(sortableName: "Susie's Feed", sortableDate: now.addingTimeInterval(-60.0), sortableArticleID: "1", sortableFeedID: "4") + let article2 = TestArticle(sortableName: "Phil's Feed", sortableDate: now.addingTimeInterval(60.0), sortableArticleID: "2", sortableFeedID: "6") + let article3 = TestArticle(sortableName: "Phil's Feed", sortableDate: now.addingTimeInterval(120.0), sortableArticleID: "3", sortableFeedID: "6") + let article4 = TestArticle(sortableName: "Susie's Feed", sortableDate: now.addingTimeInterval(-120.0), sortableArticleID: "4", sortableFeedID: "5") - let articles = [article1, article2, article3, article4, article5, article6] + let articles = [article1, article2, article3, article4] let sortedArticles = ArticleSorter.sortedByDate(articles: articles, sortDirection: .orderedAscending, groupByFeed: false) - XCTAssertEqual(sortedArticles, [article5, article4, article3, article1, article2, article6]) + + XCTAssertEqual(sortedArticles.count, articles.count) + XCTAssertEqual(sortedArticles.articleAtRow(0), article4) + XCTAssertEqual(sortedArticles.articleAtRow(1), article1) + XCTAssertEqual(sortedArticles.articleAtRow(2), article2) + XCTAssertEqual(sortedArticles.articleAtRow(3), article3) + } + + func testSortByDateAscendingWithSameDate() { + let now = Date() + + // Articles with the same date should end up being sorted by their article ID + let article1 = TestArticle(sortableName: "Phil's Feed", sortableDate: now, sortableArticleID: "1", sortableFeedID: "1") + let article2 = TestArticle(sortableName: "Matt's Feed", sortableDate: now, sortableArticleID: "2", sortableFeedID: "2") + let article3 = TestArticle(sortableName: "Sally's Feed", sortableDate: now, sortableArticleID: "3", sortableFeedID: "3") + let article4 = TestArticle(sortableName: "Susie's Feed", sortableDate: Date(timeInterval: -60.0, since: now), sortableArticleID: "4", sortableFeedID: "4") + let article5 = TestArticle(sortableName: "Paul's Feed", sortableDate: Date(timeInterval: -120.0, since: now), sortableArticleID: "5", sortableFeedID: "5") + + let articles = [article1, article2, article3, article4, article5] + let sortedArticles = ArticleSorter.sortedByDate(articles: articles, + sortDirection: .orderedAscending, + groupByFeed: false) + + XCTAssertEqual(sortedArticles.count, articles.count) + XCTAssertEqual(sortedArticles.articleAtRow(0), article5) + XCTAssertEqual(sortedArticles.articleAtRow(1), article4) + XCTAssertEqual(sortedArticles.articleAtRow(2), article1) + XCTAssertEqual(sortedArticles.articleAtRow(3), article2) + XCTAssertEqual(sortedArticles.articleAtRow(4), article3) } func testSortByDateAscendingWithGroupByFeed() { let now = Date() + + let article1 = TestArticle(sortableName: "Phil's Feed", sortableDate: Date(timeInterval: -100.0, since: now), sortableArticleID: "1", sortableFeedID: "1") + let article2 = TestArticle(sortableName: "Jenny's Feed", sortableDate: now, sortableArticleID: "1", sortableFeedID: "2") + let article3 = TestArticle(sortableName: "Jenny's Feed", sortableDate: Date(timeInterval: -10.0, since: now), sortableArticleID: "2", sortableFeedID: "2") + let article4 = TestArticle(sortableName: "Gordy's Blog", sortableDate: Date(timeInterval: -1000.0, since: now), sortableArticleID: "1", sortableFeedID: "3") + let article5 = TestArticle(sortableName: "Gordy's Blog", sortableDate: Date(timeInterval: -10.0, since: now), sortableArticleID: "2", sortableFeedID: "3") + let article6 = TestArticle(sortableName: "Jenny's Feed", sortableDate: Date(timeInterval: 10.0, since: now), sortableArticleID: "3", sortableFeedID: "2") + let article7 = TestArticle(sortableName: "Phil's Feed", sortableDate: now, sortableArticleID: "2", sortableFeedID: "1") + let article8 = TestArticle(sortableName: "Zippy's Feed", sortableDate: now, sortableArticleID: "1", sortableFeedID: "0") + let article9 = TestArticle(sortableName: "Zippy's Feed", sortableDate: now, sortableArticleID: "2", sortableFeedID: "0") - // Test data includes multiple groups (with case-insentive names), articles in the past and future, - // as well as articles with the same date - let article1 = TestArticle(sortableName: "Susie's Feed", sortableDate: Date(timeInterval: -240.0, since: now), sortableID: "123") - let article2 = TestArticle(sortableName: "Phil's Feed", sortableDate: now, sortableID: "456") - let article3 = TestArticle(sortableName: "Matt's Feed", sortableDate: now, sortableID: "234") - let article4 = TestArticle(sortableName: "Susie's Feed", sortableDate: Date(timeInterval: -120.0, since: now), sortableID: "123") - let article5 = TestArticle(sortableName: "phil's feed", sortableDate: Date(timeInterval: 60.0, since: now), sortableID: "456") - let article6 = TestArticle(sortableName: "Matt's Feed", sortableDate: now, sortableID: "123") - let article7 = TestArticle(sortableName: "Susie's Feed", sortableDate: Date(timeInterval: -60.0, since: now), sortableID: "123") - let article8 = TestArticle(sortableName: "phil's Feed", sortableDate: Date(timeInterval: -60.0, since: now), sortableID: "456") - let article9 = TestArticle(sortableName: "Matt's Feed", sortableDate: now, sortableID: "345") - let article10 = TestArticle(sortableName: "Susie's Feed", sortableDate: Date(timeInterval: -15.0, since: now), sortableID: "123") - let article11 = TestArticle(sortableName: "Matt's Feed", sortableDate: Date(timeInterval: 60.0, since: now), sortableID: "123") - let article12 = TestArticle(sortableName: "Claire's Feed", sortableDate: now, sortableID: "123") + let articles = [article1, article2, article3, article4, article5, article6, article7, article8, article9] + let sortedArticles = ArticleSorter.sortedByDate(articles: articles, sortDirection: .orderedAscending, groupByFeed: true) - let articles = [article1, article2, article3, article4, article5, article6, article7, article8, article9, article10, article11, article12] - let sortedArticles = ArticleSorter.sortedByDate(articles: articles, - sortDirection: .orderedAscending, - groupByFeed: true) - XCTAssertEqual(sortedArticles, [article12, article6, article3, article9, article11, article8, article2, article5, article1, article4, article7, article10]) + XCTAssertEqual(sortedArticles.count, 9) + + // Gordy's feed articles + XCTAssertEqual(sortedArticles.articleAtRow(0), article4) + XCTAssertEqual(sortedArticles.articleAtRow(1), article5) + // Jenny's feed articles + XCTAssertEqual(sortedArticles.articleAtRow(2), article3) + XCTAssertEqual(sortedArticles.articleAtRow(3), article2) + XCTAssertEqual(sortedArticles.articleAtRow(4), article6) + // Phil's feed articles + XCTAssertEqual(sortedArticles.articleAtRow(5), article1) + XCTAssertEqual(sortedArticles.articleAtRow(6), article7) + // Zippy's feed articles + XCTAssertEqual(sortedArticles.articleAtRow(7), article8) + XCTAssertEqual(sortedArticles.articleAtRow(8), article9) } + + // MARK: sortByDate descending tests func testSortByDateDescending() { let now = Date() - // Test data includes a mixture of articles in the past and future, as well as articles with the same date - let article1 = TestArticle(sortableName: "Phil's Feed", sortableDate: now, sortableID: "456") - let article2 = TestArticle(sortableName: "Matt's Feed", sortableDate: now, sortableID: "789") - let article3 = TestArticle(sortableName: "Sally's Feed", sortableDate: now, sortableID: "123") - let article4 = TestArticle(sortableName: "Susie's Feed", sortableDate: Date(timeInterval: -60.0, since: now), sortableID: "345") - let article5 = TestArticle(sortableName: "Paul's Feed", sortableDate: Date(timeInterval: -120.0, since: now), sortableID: "567") - let article6 = TestArticle(sortableName: "phil's Feed", sortableDate: Date(timeInterval: 60.0, since: now), sortableID: "567") + let article1 = TestArticle(sortableName: "Susie's Feed", sortableDate: now.addingTimeInterval(-60.0), sortableArticleID: "1", sortableFeedID: "4") + let article2 = TestArticle(sortableName: "Phil's Feed", sortableDate: now.addingTimeInterval(60.0), sortableArticleID: "2", sortableFeedID: "6") + let article3 = TestArticle(sortableName: "Phil's Feed", sortableDate: now.addingTimeInterval(120.0), sortableArticleID: "3", sortableFeedID: "6") + let article4 = TestArticle(sortableName: "Susie's Feed", sortableDate: now.addingTimeInterval(-120.0), sortableArticleID: "4", sortableFeedID: "5") - let articles = [article1, article2, article3, article4, article5, article6] + let articles = [article1, article2, article3, article4] let sortedArticles = ArticleSorter.sortedByDate(articles: articles, sortDirection: .orderedDescending, groupByFeed: false) - XCTAssertEqual(sortedArticles, [article6, article3, article1, article2, article4, article5]) + + XCTAssertEqual(sortedArticles.count, articles.count) + XCTAssertEqual(sortedArticles.articleAtRow(0), article3) + XCTAssertEqual(sortedArticles.articleAtRow(1), article2) + XCTAssertEqual(sortedArticles.articleAtRow(2), article1) + XCTAssertEqual(sortedArticles.articleAtRow(3), article4) + } + + func testSortByDateDescendingWithSameDate() { + let now = Date() + + // Articles with the same date should end up being sorted by their article ID + let article1 = TestArticle(sortableName: "Phil's Feed", sortableDate: now, sortableArticleID: "1", sortableFeedID: "1") + let article2 = TestArticle(sortableName: "Matt's Feed", sortableDate: now, sortableArticleID: "2", sortableFeedID: "2") + let article3 = TestArticle(sortableName: "Sally's Feed", sortableDate: now, sortableArticleID: "3", sortableFeedID: "3") + let article4 = TestArticle(sortableName: "Susie's Feed", sortableDate: Date(timeInterval: -60.0, since: now), sortableArticleID: "4", sortableFeedID: "4") + let article5 = TestArticle(sortableName: "Paul's Feed", sortableDate: Date(timeInterval: -120.0, since: now), sortableArticleID: "5", sortableFeedID: "5") + + let articles = [article1, article2, article3, article4, article5] + let sortedArticles = ArticleSorter.sortedByDate(articles: articles, + sortDirection: .orderedDescending, + groupByFeed: false) + + XCTAssertEqual(sortedArticles.count, articles.count) + XCTAssertEqual(sortedArticles.articleAtRow(0), article1) + XCTAssertEqual(sortedArticles.articleAtRow(1), article2) + XCTAssertEqual(sortedArticles.articleAtRow(2), article3) + XCTAssertEqual(sortedArticles.articleAtRow(3), article4) + XCTAssertEqual(sortedArticles.articleAtRow(4), article5) } func testSortByDateDescendingWithGroupByFeed() { let now = Date() + + let article1 = TestArticle(sortableName: "Phil's Feed", sortableDate: Date(timeInterval: -100.0, since: now), sortableArticleID: "1", sortableFeedID: "1") + let article2 = TestArticle(sortableName: "Jenny's Feed", sortableDate: now, sortableArticleID: "1", sortableFeedID: "2") + let article3 = TestArticle(sortableName: "Jenny's Feed", sortableDate: Date(timeInterval: -10.0, since: now), sortableArticleID: "2", sortableFeedID: "2") + let article4 = TestArticle(sortableName: "Gordy's Blog", sortableDate: Date(timeInterval: -1000.0, since: now), sortableArticleID: "1", sortableFeedID: "3") + let article5 = TestArticle(sortableName: "Gordy's Blog", sortableDate: Date(timeInterval: -10.0, since: now), sortableArticleID: "2", sortableFeedID: "3") + let article6 = TestArticle(sortableName: "Jenny's Feed", sortableDate: Date(timeInterval: 10.0, since: now), sortableArticleID: "3", sortableFeedID: "2") + let article7 = TestArticle(sortableName: "Phil's Feed", sortableDate: now, sortableArticleID: "2", sortableFeedID: "1") + let article8 = TestArticle(sortableName: "Zippy's Feed", sortableDate: now, sortableArticleID: "1", sortableFeedID: "0") + let article9 = TestArticle(sortableName: "Zippy's Feed", sortableDate: now, sortableArticleID: "2", sortableFeedID: "0") - // Test data includes multiple groups (with case-insentive names), articles in the past and future, - // as well as articles with the same date - let article1 = TestArticle(sortableName: "Susie's Feed", sortableDate: Date(timeInterval: -240.0, since: now), sortableID: "123") - let article2 = TestArticle(sortableName: "Phil's Feed", sortableDate: now, sortableID: "456") - let article3 = TestArticle(sortableName: "Matt's Feed", sortableDate: now, sortableID: "234") - let article4 = TestArticle(sortableName: "Susie's Feed", sortableDate: Date(timeInterval: -120.0, since: now), sortableID: "123") - let article5 = TestArticle(sortableName: "phil's feed", sortableDate: Date(timeInterval: 60.0, since: now), sortableID: "456") - let article6 = TestArticle(sortableName: "Matt's Feed", sortableDate: now, sortableID: "123") - let article7 = TestArticle(sortableName: "Susie's Feed", sortableDate: Date(timeInterval: -60.0, since: now), sortableID: "123") - let article8 = TestArticle(sortableName: "phil's Feed", sortableDate: Date(timeInterval: -60.0, since: now), sortableID: "456") - let article9 = TestArticle(sortableName: "Matt's Feed", sortableDate: now, sortableID: "345") - let article10 = TestArticle(sortableName: "Susie's Feed", sortableDate: Date(timeInterval: -15.0, since: now), sortableID: "123") - let article11 = TestArticle(sortableName: "Matt's Feed", sortableDate: Date(timeInterval: 60.0, since: now), sortableID: "123") - let article12 = TestArticle(sortableName: "Claire's Feed", sortableDate: now, sortableID: "123") + let articles = [article1, article2, article3, article4, article5, article6, article7, article8, article9] + let sortedArticles = ArticleSorter.sortedByDate(articles: articles, sortDirection: .orderedDescending, groupByFeed: true) - let articles = [article1, article2, article3, article4, article5, article6, article7, article8, article9, article10, article11, article12] - let sortedArticles = ArticleSorter.sortedByDate(articles: articles, - sortDirection: .orderedDescending, - groupByFeed: true) - XCTAssertEqual(sortedArticles, [article12, article11, article6, article3, article9, article5, article2, article8, article10, article7, article4, article1]) + XCTAssertEqual(sortedArticles.count, 9) + + // Gordy's feed articles + XCTAssertEqual(sortedArticles.articleAtRow(0), article5) + XCTAssertEqual(sortedArticles.articleAtRow(1), article4) + // Jenny's feed articles + XCTAssertEqual(sortedArticles.articleAtRow(2), article6) + XCTAssertEqual(sortedArticles.articleAtRow(3), article2) + XCTAssertEqual(sortedArticles.articleAtRow(4), article3) + // Phil's feed articles + XCTAssertEqual(sortedArticles.articleAtRow(5), article7) + XCTAssertEqual(sortedArticles.articleAtRow(6), article1) + // Zippy's feed articles + XCTAssertEqual(sortedArticles.articleAtRow(7), article8) + XCTAssertEqual(sortedArticles.articleAtRow(8), article9) } - + + // MARK: Additional group by feed tests + + func testGroupByFeedWithCaseInsensitiveFeedNames() { + let now = Date() + + let article1 = TestArticle(sortableName: "phil's feed", sortableDate: now, sortableArticleID: "1", sortableFeedID: "1") + let article2 = TestArticle(sortableName: "PhIl's FEed", sortableDate: now, sortableArticleID: "2", sortableFeedID: "1") + let article3 = TestArticle(sortableName: "APPLE's feed", sortableDate: now, sortableArticleID: "3", sortableFeedID: "2") + let article4 = TestArticle(sortableName: "PHIL'S FEED", sortableDate: now, sortableArticleID: "4", sortableFeedID: "1") + let article5 = TestArticle(sortableName: "apple's feed", sortableDate: now, sortableArticleID: "5", sortableFeedID: "2") + + let articles = [article1, article2, article3, article4, article5] + let sortedArticles = ArticleSorter.sortedByDate(articles: articles, + sortDirection: .orderedAscending, + groupByFeed: true) + + XCTAssertEqual(sortedArticles.count, articles.count) + + // Apple's feed articles + XCTAssertEqual(sortedArticles.articleAtRow(0), article3) + XCTAssertEqual(sortedArticles.articleAtRow(1), article5) + // Phil's feed articles + XCTAssertEqual(sortedArticles.articleAtRow(2), article1) + XCTAssertEqual(sortedArticles.articleAtRow(3), article2) + XCTAssertEqual(sortedArticles.articleAtRow(4), article4) + } + + func testGroupByFeedWithSameFeedNames() { + let now = Date() + + // Articles with the same feed name should be sorted by feed ID + let article1 = TestArticle(sortableName: "Phil's Feed", sortableDate: now, sortableArticleID: "1", sortableFeedID: "2") + let article2 = TestArticle(sortableName: "Phil's Feed", sortableDate: now, sortableArticleID: "2", sortableFeedID: "2") + let article3 = TestArticle(sortableName: "Phil's Feed", sortableDate: now, sortableArticleID: "3", sortableFeedID: "1") + let article4 = TestArticle(sortableName: "Phil's Feed", sortableDate: now, sortableArticleID: "4", sortableFeedID: "2") + let article5 = TestArticle(sortableName: "Phil's Feed", sortableDate: now, sortableArticleID: "5", sortableFeedID: "1") + + let articles = [article1, article2, article3, article4, article5] + let sortedArticles = ArticleSorter.sortedByDate(articles: articles, + sortDirection: .orderedAscending, + groupByFeed: true) + + XCTAssertEqual(sortedArticles.count, articles.count) + XCTAssertEqual(sortedArticles.articleAtRow(0), article3) + XCTAssertEqual(sortedArticles.articleAtRow(1), article5) + XCTAssertEqual(sortedArticles.articleAtRow(2), article1) + XCTAssertEqual(sortedArticles.articleAtRow(3), article2) + XCTAssertEqual(sortedArticles.articleAtRow(4), article4) + } + } private struct TestArticle: SortableArticle, Equatable { let sortableName: String let sortableDate: Date - let sortableID: String + let sortableArticleID: String + let sortableFeedID: String +} + +private extension Array where Element == TestArticle { + func articleAtRow(_ row: Int) -> TestArticle? { + if row < 0 || row == NSNotFound || row > count - 1 { + return nil + } + return self[row] + } + }