diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b624064..a6ddf84e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,17 @@ jobs: run: sudo xcode-select -s /Applications/Xcode_${{ matrix.xcode }}.app - name: xcodebuild ${{ matrix.scheme }} run: make DERIVED_DATA_PATH=~/.derivedData SCHEME="${{ matrix.scheme }}" xcodebuild-raw + - name: Output test failures + run: | + xcrun xcresulttool get test-results tests --path TestResults.xcresult --format json | jq -r ' + .testNodes[] + | .. | objects | select(.nodeType=="Test Case") + | . as $tc + | ($tc.children // [])[] + | select(.nodeType=="Failure Message") + | "✘ Test \($tc.name) recorded an issue at \(.name)\n" + ' + if: failure() # NB: GRDB 7.6.1 does not currently build on Linux. # linux: diff --git a/.gitignore b/.gitignore index 4bab0f42..48a51863 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ DerivedData/ .swiftpm .netrc *.sqlite +*.xcresult diff --git a/Examples/Reminders/ReminderRow.swift b/Examples/Reminders/ReminderRow.swift index e2fdc9b7..0522c1e0 100644 --- a/Examples/Reminders/ReminderRow.swift +++ b/Examples/Reminders/ReminderRow.swift @@ -8,7 +8,8 @@ struct ReminderRow: View { let reminder: Reminder let remindersList: RemindersList let showCompleted: Bool - let tags: [String] + let tags: String + let title: String? @State var editReminder: Reminder.Draft? @State var isCompleted: Bool @@ -22,7 +23,8 @@ struct ReminderRow: View { reminder: Reminder, remindersList: RemindersList, showCompleted: Bool, - tags: [String] + tags: String, + title: String? = nil ) { self.color = color self.isPastDue = isPastDue @@ -31,6 +33,7 @@ struct ReminderRow: View { self.remindersList = remindersList self.showCompleted = showCompleted self.tags = tags + self.title = title self.isCompleted = reminder.isCompleted } @@ -44,10 +47,10 @@ struct ReminderRow: View { .padding([.trailing], 5) } VStack(alignment: .leading) { - title(for: reminder) + title(for: reminder, title: title) if !notes.isEmpty { - Text(notes) + highlight(notes) .font(.subheadline) .foregroundStyle(.gray) .lineLimit(2) @@ -145,28 +148,34 @@ struct ReminderRow: View { } private var subtitleText: Text { - let tagsText = tags.reduce(Text(reminder.dueDate == nil ? "" : " ")) { result, tag in - result + Text("#\(tag) ") - } - return - (dueText - + tagsText - .foregroundStyle(.gray) - .bold()) - .font(.callout) + Text( + """ + \(dueText)\(reminder.dueDate == nil ? "" : " ")\(highlight(tags).foregroundStyle(.gray)) + """ + ) + .font(.callout) } - private func title(for reminder: Reminder) -> some View { - return HStack(alignment: .firstTextBaseline) { + @ViewBuilder + private func title(for reminder: Reminder, title: String?) -> some View { + HStack(alignment: .firstTextBaseline) { if let priority = reminder.priority { Text(String(repeating: "!", count: priority.rawValue)) .foregroundStyle(isCompleted ? .gray : remindersList.color) } - Text(reminder.title) + highlight(title ?? reminder.title) .foregroundStyle(isCompleted ? .gray : .primary) } .font(.title3) } + + func highlight(_ text: String) -> Text { + if let attributedText = try? AttributedString(markdown: text) { + Text(attributedText) + } else { + Text(text) + } + } } struct ReminderRowPreview: PreviewProvider { @@ -190,7 +199,7 @@ struct ReminderRowPreview: PreviewProvider { reminder: reminder, remindersList: remindersList, showCompleted: true, - tags: ["point-free", "adulting"] + tags: "#point-free #adulting" ) } } diff --git a/Examples/Reminders/RemindersDetail.swift b/Examples/Reminders/RemindersDetail.swift index 870b5716..1f1ea51f 100644 --- a/Examples/Reminders/RemindersDetail.swift +++ b/Examples/Reminders/RemindersDetail.swift @@ -46,7 +46,7 @@ class RemindersDetailModel: HashableObject { let ids = Array(ids.enumerated()) let (first, rest) = (ids.first!, ids.dropFirst()) $0.position = - rest + rest .reduce(Case($0.id).when(first.element, then: first.offset)) { cases, id in cases.when(id.element, then: id.offset) } @@ -58,7 +58,7 @@ class RemindersDetailModel: HashableObject { $ordering.withLock { $0 = .manual } await updateQuery() } - + private func updateQuery() async { await withErrorReporting { try await $reminderRows.load(remindersQuery, animation: .default) @@ -66,14 +66,13 @@ class RemindersDetailModel: HashableObject { } private var remindersQuery: some StructuredQueriesCore.Statement { - let query = Reminder .where { if !showCompleted { !$0.isCompleted } } - .order { $0.isCompleted } + .order(by: \.isCompleted) .order { switch ordering { case .dueDate: $0.dueDate.asc(nulls: .last) @@ -95,16 +94,16 @@ class RemindersDetailModel: HashableObject { } } .join(RemindersList.all) { $0.remindersListID.eq($3.id) } + .join(ReminderText.all) { $0.rowid.eq($4.rowid) } .select { Row.Columns( reminder: $0, remindersList: $3, isPastDue: $0.isPastDue, - notes: $0.inlineNotes.substr(0, 200), - tags: #sql("\($2.jsonTitles)") + notes: $4.notes.substr(0, 200), + tags: $4.tags ) } - return query } enum Ordering: String, CaseIterable { @@ -141,8 +140,7 @@ class RemindersDetailModel: HashableObject { let remindersList: RemindersList let isPastDue: Bool let notes: String - @Column(as: [String].JSONRepresentation.self) - let tags: [String] + let tags: String } } @@ -191,7 +189,7 @@ struct RemindersDetailView: View { reminder: Reminder.Draft(remindersListID: remindersList.id), remindersList: remindersList ) - .navigationTitle("New Reminder") + .navigationTitle("New Reminder") } } } diff --git a/Examples/Reminders/RemindersLists.swift b/Examples/Reminders/RemindersLists.swift index c9889ad5..e6daf88c 100644 --- a/Examples/Reminders/RemindersLists.swift +++ b/Examples/Reminders/RemindersLists.swift @@ -173,7 +173,9 @@ struct RemindersListsView: View { var body: some View { List { - if model.searchRemindersModel.searchText.isEmpty { + if model.searchRemindersModel.isSearching { + SearchRemindersView(model: model.searchRemindersModel) + } else { Section { Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 16) { GridRow { @@ -269,8 +271,6 @@ struct RemindersListsView: View { .padding([.leading, .trailing], 4) } .listRowInsets(EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)) - } else { - SearchRemindersView(model: model.searchRemindersModel) } } .onAppear { @@ -328,7 +328,17 @@ struct RemindersListsView: View { } .presentationDetents([.medium]) } - .searchable(text: $model.searchRemindersModel.searchText) + .searchable( + text: $model.searchRemindersModel.searchText, + tokens: $model.searchRemindersModel.searchTokens + ) { token in + switch token.kind { + case .near: + Text(token.rawValue) + case .tag: + Text("#\(token.rawValue)") + } + } .navigationDestination(item: $model.destination.detail) { detailModel in RemindersDetailView(model: detailModel) } diff --git a/Examples/Reminders/Schema.swift b/Examples/Reminders/Schema.swift index ab54ffff..b7808a66 100644 --- a/Examples/Reminders/Schema.swift +++ b/Examples/Reminders/Schema.swift @@ -49,12 +49,6 @@ enum Priority: Int, Codable, QueryBindable { extension Reminder { static let incomplete = Self.where { !$0.isCompleted } - static func searching(_ text: String) -> Where { - Self.where { - $0.title.collate(.nocase).contains(text) - || $0.notes.collate(.nocase).contains(text) - } - } static let withTags = group(by: \.id) .leftJoin(ReminderTag.all) { $0.id.eq($1.reminderID) } .leftJoin(Tag.all) { $1.tagID.eq($2.primaryKey) } @@ -72,9 +66,6 @@ extension Reminder.TableColumns { var isScheduled: some QueryExpression { !isCompleted && dueDate.isNot(nil) } - var inlineNotes: some QueryExpression { - notes.replace("\n", " ") - } } extension Tag { @@ -83,12 +74,6 @@ extension Tag { .leftJoin(Reminder.all) { $1.reminderID.eq($2.id) } } -extension Tag.TableColumns { - var jsonTitles: some QueryExpression<[String].JSONRepresentation> { - self.title.jsonGroupArray(filter: self.title.isNot(nil)) - } -} - @Table("remindersTags") struct ReminderTag: Hashable, Identifiable { let id: UUID @@ -96,6 +81,14 @@ struct ReminderTag: Hashable, Identifiable { var tagID: Tag.ID } +@Table @Selection +struct ReminderText: StructuredQueries.FTS5 { + let rowid: Int + let title: String + let notes: String + let tags: String +} + func appDatabase() throws -> any DatabaseWriter { @Dependency(\.context) var context let database: any DatabaseWriter @@ -173,6 +166,18 @@ func appDatabase() throws -> any DatabaseWriter { """ ) .execute(db) + try #sql( + """ + CREATE VIRTUAL TABLE "reminderTexts" USING fts5( + "reminderID" UNINDEXED, + "title", + "notes", + "tags", + tokenize = 'trigram' + ) + """ + ) + .execute(db) } try migrator.migrate(database) @@ -188,12 +193,14 @@ func appDatabase() throws -> any DatabaseWriter { .update { $0.position = RemindersList.select { ($0.position.max() ?? -1) + 1} } }) .execute(db) + try Reminder.createTemporaryTrigger(after: .insert { new in Reminder .find(new.id) .update { $0.position = Reminder.select { ($0.position.max() ?? -1) + 1} } }) .execute(db) + try RemindersList.createTemporaryTrigger(after: .delete { _ in RemindersList.insert { RemindersList.Draft( @@ -205,6 +212,61 @@ func appDatabase() throws -> any DatabaseWriter { !RemindersList.exists() }) .execute(db) + + try Reminder.createTemporaryTrigger(after: .insert { new in + ReminderText.insert { + ReminderText.Columns( + rowid: new.rowid, + title: new.title, + notes: new.notes.replace("\n", " "), + tags: "" + ) + } + }) + .execute(db) + + try Reminder.createTemporaryTrigger(after: .update { + ($0.title, $0.notes) + } forEachRow: { _, new in + ReminderText + .where { $0.rowid.eq(new.rowid) } + .update { + $0.title = new.title + $0.notes = new.notes.replace("\n", " ") + } + }) + .execute(db) + + try Reminder.createTemporaryTrigger(after: .delete { old in + ReminderText + .where { $0.rowid.eq(old.rowid) } + .delete() + }) + .execute(db) + + func updateReminderTextTags( + for reminderID: some QueryExpression + ) -> UpdateOf { + ReminderText + .where { $0.rowid.eq(Reminder.find(reminderID).select(\.rowid)) } + .update { + $0.tags = ReminderTag + .order(by: \.tagID) + .where { $0.reminderID.eq(reminderID) } + .join(Tag.all) { $0.tagID.eq($1.primaryKey) } + .select { ("#" + $1.title).groupConcat(" ") ?? "" } + } + } + + try ReminderTag.createTemporaryTrigger(after: .insert { new in + updateReminderTextTags(for: new.reminderID) + }) + .execute(db) + + try ReminderTag.createTemporaryTrigger(after: .delete { old in + updateReminderTextTags(for: old.reminderID) + }) + .execute(db) } return database diff --git a/Examples/Reminders/SearchReminders.swift b/Examples/Reminders/SearchReminders.swift index 1c73ba6b..472ab5ec 100644 --- a/Examples/Reminders/SearchReminders.swift +++ b/Examples/Reminders/SearchReminders.swift @@ -5,29 +5,67 @@ import SwiftUI @MainActor @Observable class SearchRemindersModel { - var showCompletedInSearchResults = false + var showCompletedInSearchResults = false { + didSet { + searchTask = Task { try await updateQuery(debounce: false) } + } + } + var searchText = "" { didSet { - Task { await updateQuery() } + if oldValue != searchText { + if searchText.hasSuffix("\t") { + searchTokens.append(Token(kind: .near, rawValue: String(searchText.dropLast()))) + searchText = "" + } + + searchTask = Task { try await updateQuery() } + } + } + } + + var searchTokens: [Token] = [] { + didSet { + if oldValue != searchTokens { + searchTask = Task { try await updateQuery() } + } + } + } + + var isSearching: Bool { + !searchText.isEmpty || !searchTokens.isEmpty + } + + var searchTask: Task? { + willSet { + searchTask?.cancel() } } - @ObservationIgnored @FetchOne var completedCount: Int = 0 - @ObservationIgnored @FetchAll var reminders: [Row] + @ObservationIgnored @Dependency(\.continuousClock) private var clock @ObservationIgnored @Dependency(\.defaultDatabase) private var database - func showCompletedButtonTapped() async { + @ObservationIgnored @Fetch var searchResults = SearchRequest.Value() + + @ObservationIgnored @FetchAll(Tag.none) var tags + + func showCompletedButtonTapped() async throws { showCompletedInSearchResults.toggle() - await updateQuery() + try await updateQuery() + } + + func tagButtonTapped(_ tag: Tag) { + guard !searchText.isEmpty else { return } + searchTokens.append(Token(kind: .tag, rawValue: tag.title)) + searchText = "" } func deleteCompletedReminders(monthsAgo: Int? = nil) { withErrorReporting { try database.write { db in try Reminder - .searching(searchText) - .where(\.isCompleted) + .where { $0.isCompleted && $0.id.in(baseQuery.select { $1.id }) } .where { if let monthsAgo { #sql("\($0.dueDate) < date('now', '-\(raw: monthsAgo) months')") @@ -39,51 +77,118 @@ class SearchRemindersModel { } } - private func updateQuery() async { + private var baseQuery: SelectOf { + let searchText = searchText.quoted() + + return + ReminderText + .where { + if !searchText.isEmpty { + $0.match(searchText) + } + } + .where { + for token in searchTokens { + switch token.kind { + case .near: + $0.match("NEAR(\(token.rawValue.quoted())") + case .tag: + $0.tags.match(token.rawValue) + } + } + } + .join(Reminder.all) { $0.rowid.eq($1.rowid) } + } + + private func updateQuery(debounce: Bool = true) async throws { + if debounce { + try await clock.sleep(for: .seconds(0.3)) + } await withErrorReporting { - if searchText.isEmpty { + if !isSearching { showCompletedInSearchResults = false } - try await $completedCount.load( - Reminder.searching(searchText) - .where(\.isCompleted) - .count(), - animation: .default - ) - try await $reminders.load( - Reminder - .searching(searchText) + + if searchText.hasPrefix("#") { + let existingTags = searchTokens.compactMap { $0.kind == .tag ? $0.rawValue : nil } + try await $tags.load( + Tag + .where { $0.title.hasPrefix(searchText.dropFirst()) && !$0.title.in(existingTags) } + .order(by: \.title) + ) + } else { + try await $searchResults.load( + SearchRequest( + baseQuery: baseQuery, + showCompletedInSearchResults: showCompletedInSearchResults + ), + animation: .default + ) + } + } + } + + @Selection + struct Row: Identifiable { + var id: Reminder.ID { reminder.id } + let isPastDue: Bool + let notes: String + let reminder: Reminders.Reminder + let remindersList: RemindersList + let tags: String + let title: String + } + + struct SearchRequest: FetchKeyRequest { + struct Value { + var completedCount = 0 + var rows: [Row] = [] + } + let baseQuery: SelectOf + let showCompletedInSearchResults: Bool + func fetch(_ db: Database) throws -> Value { + try Value( + completedCount: + baseQuery + .where { $1.isCompleted } + .count() + .fetchOne(db) ?? 0, + rows: + baseQuery .where { if !showCompletedInSearchResults { - !$0.isCompleted + !$1.isCompleted } } - .order { ($0.isCompleted, $0.dueDate) } - .withTags - .join(RemindersList.all) { $0.remindersListID.eq($3.id) } + .order { + ($1.isCompleted, $1.dueDate) + } + .join(RemindersList.all) { $1.remindersListID.eq($2.id) } .select { Row.Columns( - isPastDue: $0.isPastDue, - notes: $0.inlineNotes, - reminder: $0, - remindersList: $3, - tags: #sql("\($2.jsonTitles)") + isPastDue: $1.isPastDue, + notes: $0.notes.snippet("**", "**", "...", 64).replace("\n", " "), + reminder: $1, + remindersList: $2, + tags: $0.tags.highlight("**", "**"), + title: $0.title.highlight("**", "**") ) - }, - animation: .default + } + .fetchAll(db) ) } } - @Selection - struct Row: Identifiable { - var id: Reminder.ID { reminder.id } - let isPastDue: Bool - let notes: String - let reminder: Reminders.Reminder - let remindersList: RemindersList - @Column(as: [String].JSONRepresentation.self) - let tags: [String] + struct Token: Hashable, Identifiable { + enum Kind { + case near + case tag + } + + var kind: Kind + var rawValue = "" + + var id: Self { self } } } @@ -95,11 +200,26 @@ struct SearchRemindersView: View { } var body: some View { + if model.searchText.hasPrefix("#"), !model.tags.isEmpty { + Section { + ScrollView(.horizontal) { + HStack { + ForEach(model.tags) { tag in + Button("#\(tag.title)") { + model.tagButtonTapped(tag) + } + } + } + } + .scrollIndicators(.hidden) + } + } + HStack { - Text("\(model.completedCount) Completed") + Text("\(model.searchResults.completedCount) Completed") .monospacedDigit() .contentTransition(.numericText()) - if model.completedCount > 0 { + if model.searchResults.completedCount > 0 { Text("•") Menu { Text("Clear Completed Reminders") @@ -120,21 +240,22 @@ struct SearchRemindersView: View { } Spacer() Button(model.showCompletedInSearchResults ? "Hide" : "Show") { - Task { await model.showCompletedButtonTapped() } + Task { try await model.showCompletedButtonTapped() } } } } .buttonStyle(.borderless) - ForEach(model.reminders) { reminder in + ForEach(model.searchResults.rows) { row in ReminderRow( - color: reminder.remindersList.color, - isPastDue: reminder.isPastDue, - notes: reminder.notes, - reminder: reminder.reminder, - remindersList: reminder.remindersList, + color: row.remindersList.color, + isPastDue: row.isPastDue, + notes: row.notes, + reminder: row.reminder, + remindersList: row.remindersList, showCompleted: model.showCompletedInSearchResults, - tags: reminder.tags + tags: row.tags, + title: row.title ) } } @@ -157,3 +278,11 @@ struct SearchRemindersView: View { .searchable(text: $searchText) } } + +extension String { + fileprivate func quoted() -> String { + split(separator: " ") + .map { #""\#($0.replacingOccurrences(of: #"""#, with: #""""#))""# } + .joined(separator: " ") + } +} diff --git a/Examples/RemindersTests/Internal.swift b/Examples/RemindersTests/Internal.swift index a0ea19c6..35986dd8 100644 --- a/Examples/RemindersTests/Internal.swift +++ b/Examples/RemindersTests/Internal.swift @@ -7,6 +7,7 @@ import Testing @testable import Reminders @Suite( + .dependency(\.continuousClock, ImmediateClock()), .dependency(\.date.now, Date(timeIntervalSince1970: 1234567890)), .dependency(\.uuid, .incrementing), .dependencies { diff --git a/Examples/RemindersTests/RemindersDetailsTests.swift b/Examples/RemindersTests/RemindersDetailsTests.swift index b51c7aa8..c4f9cd3d 100644 --- a/Examples/RemindersTests/RemindersDetailsTests.swift +++ b/Examples/RemindersTests/RemindersDetailsTests.swift @@ -38,10 +38,7 @@ extension BaseTestSuite { ), isPastDue: true, notes: "", - tags: [ - [0]: "optional", - [1]: "someday" - ] + tags: "#someday #optional" ), [1]: RemindersDetailModel.Row( reminder: Reminder( @@ -63,9 +60,7 @@ extension BaseTestSuite { ), isPastDue: false, notes: "Ask about diet", - tags: [ - [0]: "adulting" - ] + tags: "#adulting" ), [2]: RemindersDetailModel.Row( reminder: Reminder( @@ -87,10 +82,7 @@ extension BaseTestSuite { ), isPastDue: false, notes: "", - tags: [ - [0]: "night", - [1]: "social" - ] + tags: "#social #night" ), [3]: RemindersDetailModel.Row( reminder: Reminder( @@ -118,11 +110,7 @@ extension BaseTestSuite { ), isPastDue: false, notes: "Milk Eggs Apples Oatmeal Spinach", - tags: [ - [0]: "adulting", - [1]: "optional", - [2]: "someday" - ] + tags: "#someday #optional #adulting" ) ] """# diff --git a/Examples/RemindersTests/SearchRemindersTests.swift b/Examples/RemindersTests/SearchRemindersTests.swift index 66d8b7e7..8faeceb1 100644 --- a/Examples/RemindersTests/SearchRemindersTests.swift +++ b/Examples/RemindersTests/SearchRemindersTests.swift @@ -8,156 +8,163 @@ import Testing extension BaseTestSuite { @MainActor + @Suite( + .snapshots(record: .missing) + ) struct SearchRemindersTests { @Dependency(\.defaultDatabase) var database @Test func basics() async throws { let model = SearchRemindersModel() - try await model.$reminders.load() - try await model.$completedCount.load() + try await model.$searchResults.load() - #expect(model.completedCount == 0) - assertInlineSnapshot(of: model.reminders, as: .customDump) { + #expect(model.searchResults.completedCount == 0) + assertInlineSnapshot(of: model.searchResults.rows, as: .customDump) { """ [] """ } model.searchText = "Take" - try await model.$reminders.load() - try await model.$completedCount.load() - try await Task.sleep(for: .seconds(0.5)) - #expect(model.completedCount == 1) - assertInlineSnapshot(of: model.reminders, as: .customDump) { - """ - [ - [0]: SearchRemindersModel.Row( - isPastDue: false, - notes: "", - reminder: Reminder( - id: UUID(00000000-0000-0000-0000-00000000000A), - dueDate: Date(2009-02-17T23:31:30.000Z), - isCompleted: false, - isFlagged: false, + try await model.searchTask?.value + #expect(model.searchResults.completedCount == 1) + withKnownIssue( + "'@Fetch' introduces an escaping closure and loses the task-local dependency" + ) { + assertInlineSnapshot(of: model.searchResults.rows, as: .customDump) { + """ + [ + [0]: SearchRemindersModel.Row( + isPastDue: false, notes: "", - position: 8, - priority: .high, - remindersListID: UUID(00000000-0000-0000-0000-000000000001), - title: "Take out trash" - ), - remindersList: RemindersList( - id: UUID(00000000-0000-0000-0000-000000000001), - color: 3985191935, - position: 2, - title: "Family" - ), - tags: [] - ) - ] - """ + reminder: Reminder( + id: UUID(00000000-0000-0000-0000-00000000000A), + dueDate: Date(2009-02-17T23:31:30.000Z), + isCompleted: false, + isFlagged: false, + notes: "", + position: 8, + priority: .high, + remindersListID: UUID(00000000-0000-0000-0000-000000000001), + title: "Take out trash" + ), + remindersList: RemindersList( + id: UUID(00000000-0000-0000-0000-000000000001), + color: 3985191935, + position: 2, + title: "Family" + ), + tags: "", + title: "**Take** out trash" + ) + ] + """ + } } } @Test func showCompleted() async throws { let model = SearchRemindersModel() model.searchText = "Take" - await model.showCompletedButtonTapped() - try await Task.sleep(for: .seconds(0.1)) - try await model.$reminders.load() - try await model.$completedCount.load() + try await model.showCompletedButtonTapped() - assertInlineSnapshot(of: model.reminders, as: .customDump) { - """ - [ - [0]: SearchRemindersModel.Row( - isPastDue: false, - notes: "", - reminder: Reminder( - id: UUID(00000000-0000-0000-0000-00000000000A), - dueDate: Date(2009-02-17T23:31:30.000Z), - isCompleted: false, - isFlagged: false, + withKnownIssue( + "'@Fetch' introduces an escaping closure and loses the task-local dependency" + ) { + assertInlineSnapshot(of: model.searchResults.rows, as: .customDump) { + """ + [ + [0]: SearchRemindersModel.Row( + isPastDue: false, notes: "", - position: 8, - priority: .high, - remindersListID: UUID(00000000-0000-0000-0000-000000000001), - title: "Take out trash" - ), - remindersList: RemindersList( - id: UUID(00000000-0000-0000-0000-000000000001), - color: 3985191935, - position: 2, - title: "Family" + reminder: Reminder( + id: UUID(00000000-0000-0000-0000-00000000000A), + dueDate: Date(2009-02-17T23:31:30.000Z), + isCompleted: false, + isFlagged: false, + notes: "", + position: 8, + priority: .high, + remindersListID: UUID(00000000-0000-0000-0000-000000000001), + title: "Take out trash" + ), + remindersList: RemindersList( + id: UUID(00000000-0000-0000-0000-000000000001), + color: 3985191935, + position: 2, + title: "Family" + ), + tags: "", + title: "**Take** out trash" ), - tags: [] - ), - [1]: SearchRemindersModel.Row( - isPastDue: false, - notes: "", - reminder: Reminder( - id: UUID(00000000-0000-0000-0000-000000000006), - dueDate: Date(2008-08-07T23:31:30.000Z), - isCompleted: true, - isFlagged: false, + [1]: SearchRemindersModel.Row( + isPastDue: false, notes: "", - position: 4, - priority: nil, - remindersListID: UUID(00000000-0000-0000-0000-000000000000), - title: "Take a walk" - ), - remindersList: RemindersList( - id: UUID(00000000-0000-0000-0000-000000000000), - color: 1218047999, - position: 1, - title: "Personal" - ), - tags: [ - [0]: "car", - [1]: "kids", - [2]: "social" - ] - ) - ] - """ + reminder: Reminder( + id: UUID(00000000-0000-0000-0000-000000000006), + dueDate: Date(2008-08-07T23:31:30.000Z), + isCompleted: true, + isFlagged: false, + notes: "", + position: 4, + priority: nil, + remindersListID: UUID(00000000-0000-0000-0000-000000000000), + title: "Take a walk" + ), + remindersList: RemindersList( + id: UUID(00000000-0000-0000-0000-000000000000), + color: 1218047999, + position: 1, + title: "Personal" + ), + tags: "#car #kids #social", + title: "**Take** a walk" + ) + ] + """ + } } } @Test func deleteCompleted() async throws { let model = SearchRemindersModel() model.searchText = "Take" - await model.showCompletedButtonTapped() - try await Task.sleep(for: .seconds(0.1)) + try await model.showCompletedButtonTapped() model.deleteCompletedReminders() - try await model.$reminders.load() - try await model.$completedCount.load() - #expect(model.completedCount == 0) - assertInlineSnapshot(of: model.reminders, as: .customDump) { - """ - [ - [0]: SearchRemindersModel.Row( - isPastDue: false, - notes: "", - reminder: Reminder( - id: UUID(00000000-0000-0000-0000-00000000000A), - dueDate: Date(2009-02-17T23:31:30.000Z), - isCompleted: false, - isFlagged: false, + try await model.$searchResults.load() + #expect(model.searchResults.completedCount == 0) + withKnownIssue( + "'@Fetch' introduces an escaping closure and loses the task-local dependency" + ) { + assertInlineSnapshot(of: model.searchResults.rows, as: .customDump) { + """ + [ + [0]: SearchRemindersModel.Row( + isPastDue: false, notes: "", - position: 8, - priority: .high, - remindersListID: UUID(00000000-0000-0000-0000-000000000001), - title: "Take out trash" - ), - remindersList: RemindersList( - id: UUID(00000000-0000-0000-0000-000000000001), - color: 3985191935, - position: 2, - title: "Family" - ), - tags: [] - ) - ] - """ + reminder: Reminder( + id: UUID(00000000-0000-0000-0000-00000000000A), + dueDate: Date(2009-02-17T23:31:30.000Z), + isCompleted: false, + isFlagged: false, + notes: "", + position: 8, + priority: .high, + remindersListID: UUID(00000000-0000-0000-0000-000000000001), + title: "Take out trash" + ), + remindersList: RemindersList( + id: UUID(00000000-0000-0000-0000-000000000001), + color: 3985191935, + position: 2, + title: "Family" + ), + tags: "", + title: "**Take** out trash" + ) + ] + """ + } } } } diff --git a/Makefile b/Makefile index 919599f7..5d1b0122 100644 --- a/Makefile +++ b/Makefile @@ -15,9 +15,10 @@ XCODEBUILD_FLAGS = \ -configuration $(CONFIG) \ -derivedDataPath $(DERIVED_DATA_PATH) \ -destination $(DESTINATION) \ + -project Examples/Examples.xcodeproj \ + -resultBundlePath TestResults.xcresult \ -scheme "$(SCHEME)" \ - -skipMacroValidation \ - -project Examples/Examples.xcodeproj + -skipMacroValidation XCODEBUILD_COMMAND = xcodebuild $(XCODEBUILD_ARGUMENT) $(XCODEBUILD_FLAGS)