git.iko.soy/parseable-macos
on master help sign in
Implement 8 performance optimizations: async width/sort computation, filter debouncing, caching (#45)
f7b34e9iko
ParseableViewer/Services/ParseableClient.swift+15−10
@@ -227,16 +227,21 @@ final class ParseableClient: Sendable {
227 return []
227 return []
228 }
228 }
229
229
230− // Handle both response formats:
230+ // Peek at the first non-whitespace byte to choose the decoder path:
231− // 1. Wrapped: {"records": [...], "fields": [...]}
231+ // '{' → wrapped response, '[' → direct array.
232− // 2. Direct array: [...]
232+ let firstByte = responseData.first(where: { $0 != 0x20 && $0 != 0x0A && $0 != 0x0D && $0 != 0x09 })
233+ if firstByte == 0x7B { // '{'
234+ do {
233− if let wrapped = try? JSONDecoder().decode(QueryResponse.self, from: responseData) {
235+ return try JSONDecoder().decode(QueryResponse.self, from: responseData).records
234− return wrapped.records
236+ } catch {
235− }
237+ throw ParseableError.decodingError("Unexpected query response format")
238+ }
239+ } else {
236− do {
240+ do {
237− return try JSONDecoder().decode([LogRecord].self, from: responseData)
241+ return try JSONDecoder().decode([LogRecord].self, from: responseData)
238− } catch {
242+ } catch {
239− throw ParseableError.decodingError("Unexpected query response format")
243+ throw ParseableError.decodingError("Unexpected query response format")
244+ }
240 }
245 }
241 }
246 }
242
247
ParseableViewer/ViewModels/LiveTailViewModel.swift+77−11
@@ -7,7 +7,9 @@ final class LiveTailViewModel {
7 var entries: [LiveTailEntry] = []
7 var entries: [LiveTailEntry] = []
8 var isRunning = false
8 var isRunning = false
9 var isPaused = false
9 var isPaused = false
10− var filterText = ""
10+ var filterText = "" {
11+ didSet { rebuildFilteredEntries() }
12+ }
11 var columnFilters: [ColumnFilter] = []
13 var columnFilters: [ColumnFilter] = []
12 var pollInterval: TimeInterval = 2.0
14 var pollInterval: TimeInterval = 2.0
13 var maxEntries = 5000
15 var maxEntries = 5000
@@ -73,10 +75,12 @@ final class LiveTailViewModel {
73 }
75 }
74 }
76 }
75
77
76− var filteredEntries: [LiveTailEntry] {
78+ private(set) var cachedFilteredEntries: [LiveTailEntry] = []
79+ private(set) var filteredEntriesGeneration: Int = 0
80+
81+ private func rebuildFilteredEntries() {
77 var result = entries
82 var result = entries
78
83
79− // Apply column filters
80 for filter in columnFilters {
84 for filter in columnFilters {
81 result = result.filter { entry in
85 result = result.filter { entry in
82 let recordValue = entry.record[filter.column]
86 let recordValue = entry.record[filter.column]
@@ -90,7 +94,6 @@ final class LiveTailViewModel {
90 }
94 }
91 }
95 }
92
96
93− // Apply text filter
94 if !filterText.isEmpty {
97 if !filterText.isEmpty {
95 result = result.filter { entry in
98 result = result.filter { entry in
96 entry.summary.localizedCaseInsensitiveContains(filterText) ||
99 entry.summary.localizedCaseInsensitiveContains(filterText) ||
@@ -98,25 +101,29 @@ final class LiveTailViewModel {
98 }
101 }
99 }
102 }
100
103
101− return result
104+ cachedFilteredEntries = result
105+ filteredEntriesGeneration += 1
102 }
106 }
103
107
104 func addColumnFilter(column: String, value: JSONValue?, exclude: Bool) {
108 func addColumnFilter(column: String, value: JSONValue?, exclude: Bool) {
105 // Remove any existing filter on the same column with the same value
109 // Remove any existing filter on the same column with the same value
106 columnFilters.removeAll { $0.column == column && $0.value == value && $0.exclude == exclude }
110 columnFilters.removeAll { $0.column == column && $0.value == value && $0.exclude == exclude }
107 columnFilters.append(ColumnFilter(column: column, value: value, exclude: exclude))
111 columnFilters.append(ColumnFilter(column: column, value: value, exclude: exclude))
112+ rebuildFilteredEntries()
108 }
113 }
109
114
110 func removeColumnFilter(_ filter: ColumnFilter) {
115 func removeColumnFilter(_ filter: ColumnFilter) {
111 columnFilters.removeAll { $0.id == filter.id }
116 columnFilters.removeAll { $0.id == filter.id }
117+ rebuildFilteredEntries()
112 }
118 }
113
119
114 func clearColumnFilters() {
120 func clearColumnFilters() {
115 columnFilters.removeAll()
121 columnFilters.removeAll()
122+ rebuildFilteredEntries()
116 }
123 }
117
124
118 var entryCount: Int { entries.count }
125 var entryCount: Int { entries.count }
119− var displayedCount: Int { filteredEntries.count }
126+ var displayedCount: Int { cachedFilteredEntries.count }
120
127
121 func start(client: ParseableClient?, stream: String?) {
128 func start(client: ParseableClient?, stream: String?) {
122 guard let client, let stream, !isRunning else { return }
129 guard let client, let stream, !isRunning else { return }
@@ -141,6 +148,7 @@ final class LiveTailViewModel {
141 hiddenColumns = []
148 hiddenColumns = []
142 columnFilters = []
149 columnFilters = []
143 currentStream = stream
150 currentStream = stream
151+ rebuildFilteredEntries()
144
152
145 timer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in
153 timer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in
146 Task { @MainActor [weak self] in
154 Task { @MainActor [weak self] in
@@ -169,6 +177,7 @@ final class LiveTailViewModel {
169 columnOrder = []
177 columnOrder = []
170 hiddenColumns = []
178 hiddenColumns = []
171 columnFilters = []
179 columnFilters = []
180+ rebuildFilteredEntries()
172 }
181 }
173
182
174 // MARK: - Column Management
183 // MARK: - Column Management
@@ -337,6 +346,7 @@ final class LiveTailViewModel {
337 updateColumns(stream: stream)
346 updateColumns(stream: stream)
338 }
347 }
339
348
349+ rebuildFilteredEntries()
340 lastTimestamp = now
350 lastTimestamp = now
341 lastPollTime = now
351 lastPollTime = now
342 errorMessage = nil
352 errorMessage = nil
@@ -364,16 +374,72 @@ final class LiveTailViewModel {
364 }
374 }
365 h0 = (h0 ^ 0) &* 1099511628211 // separator
375 h0 = (h0 ^ 0) &* 1099511628211 // separator
366 if let val = record[key] {
376 if let val = record[key] {
367− // Use exportString so nested objects/arrays contribute their
377+ hashJSONValue(val, h0: &h0, h1: &h1)
368− // actual content to the hash, not just their field count.
378+ }
379+ h1 = (h1 ^ 0xFF) &* 6700417 // field separator
380+ }
381+ return String(h0, radix: 36) + String(h1, radix: 36)
382+ }
383+
384+ /// Recursively hashes a JSONValue tree without allocating intermediate strings.
385+ /// Uses type-discriminator bytes to prevent cross-type collisions.
386+ private static func hashJSONValue(_ value: JSONValue, h0: inout UInt64, h1: inout UInt64) {
387+ switch value {
388+ case .null:
389+ h0 = (h0 ^ 0x00) &* 1099511628211
390+ h1 = (h1 ^ 0x00) &* 6700417
391+ case .bool(let b):
392+ h0 = (h0 ^ 0x01) &* 1099511628211
393+ h1 = (h1 ^ 0x01) &* 6700417
394+ let byte: UInt64 = b ? 1 : 0
395+ h0 = (h0 ^ byte) &* 1099511628211
396+ h1 = (h1 ^ byte) &* 6700417
397+ case .int(let i):
398+ h0 = (h0 ^ 0x02) &* 1099511628211
399+ h1 = (h1 ^ 0x02) &* 6700417
400+ var bits = UInt64(bitPattern: Int64(i))
401+ for _ in 0..<8 {
402+ h0 = (h0 ^ (bits & 0xFF)) &* 1099511628211
403+ h1 = (h1 ^ (bits & 0xFF)) &* 6700417
404+ bits >>= 8
405+ }
406+ case .double(let d):
407+ h0 = (h0 ^ 0x03) &* 1099511628211
408+ h1 = (h1 ^ 0x03) &* 6700417
409+ var bits = d.bitPattern
410+ for _ in 0..<8 {
411+ h0 = (h0 ^ UInt64(bits & 0xFF)) &* 1099511628211
412+ h1 = (h1 ^ UInt64(bits & 0xFF)) &* 6700417
413+ bits >>= 8
414+ }
415+ case .string(let s):
416+ h0 = (h0 ^ 0x04) &* 1099511628211
417+ h1 = (h1 ^ 0x04) &* 6700417
369− for byte in val.exportString.utf8 {
418+ for byte in s.utf8 {
419+ h0 = (h0 ^ UInt64(byte)) &* 1099511628211
420+ h1 = (h1 ^ UInt64(byte)) &* 6700417
421+ }
422+ case .array(let arr):
423+ h0 = (h0 ^ 0x05) &* 1099511628211
424+ h1 = (h1 ^ 0x05) &* 6700417
425+ for element in arr {
426+ hashJSONValue(element, h0: &h0, h1: &h1)
427+ h0 = (h0 ^ 0xFE) &* 1099511628211 // element separator
428+ }
429+ case .object(let dict):
430+ h0 = (h0 ^ 0x06) &* 1099511628211
431+ h1 = (h1 ^ 0x06) &* 6700417
432+ for key in dict.keys.sorted() {
433+ for byte in key.utf8 {
370 h0 = (h0 ^ UInt64(byte)) &* 1099511628211
434 h0 = (h0 ^ UInt64(byte)) &* 1099511628211
371 h1 = (h1 ^ UInt64(byte)) &* 6700417
435 h1 = (h1 ^ UInt64(byte)) &* 6700417
372 }
436 }
437+ h0 = (h0 ^ 0xFD) &* 1099511628211 // key-value separator
438+ if let val = dict[key] {
439+ hashJSONValue(val, h0: &h0, h1: &h1)
440+ }
373 }
441 }
374− h1 = (h1 ^ 0xFF) &* 6700417 // field separator
375 }
442 }
376− return String(h0, radix: 36) + String(h1, radix: 36)
377 }
443 }
378
444
379 func parseTimestamp(from record: LogRecord) -> Date? {
445 func parseTimestamp(from record: LogRecord) -> Date? {
ParseableViewer/ViewModels/QueryViewModel.swift+15−1
@@ -21,8 +21,9 @@ final class QueryViewModel {
21 var queryDuration: TimeInterval?
21 var queryDuration: TimeInterval?
22 var selectedLogEntry: LogRecord?
22 var selectedLogEntry: LogRecord?
23 var filterText = "" {
23 var filterText = "" {
24− didSet { updateFilteredResults() }
24+ didSet { debouncedUpdateFilter() }
25 }
25 }
26+ private var filterTask: Task<Void, Never>?
26 var resultsTruncated = false
27 var resultsTruncated = false
27
28
28 // Schema fields for autocomplete
29 // Schema fields for autocomplete
@@ -248,6 +249,19 @@ final class QueryViewModel {
248
249
249 private(set) var filteredResults: [LogRecord] = []
250 private(set) var filteredResults: [LogRecord] = []
250
251
252+ private func debouncedUpdateFilter() {
253+ filterTask?.cancel()
254+ if filterText.isEmpty {
255+ updateFilteredResults()
256+ return
257+ }
258+ filterTask = Task {
259+ try? await Task.sleep(for: .milliseconds(150))
260+ guard !Task.isCancelled else { return }
261+ updateFilteredResults()
262+ }
263+ }
264+
251 private func updateFilteredResults() {
265 private func updateFilteredResults() {
252 if filterText.isEmpty {
266 if filterText.isEmpty {
253 filteredResults = results
267 filteredResults = results
ParseableViewer/Views/LiveTailView.swift+43−19
@@ -11,6 +11,8 @@ struct LiveTailView: View {
11 @State private var sortColumn: String?
11 @State private var sortColumn: String?
12 @State private var sortAscending = false
12 @State private var sortAscending = false
13 @State private var columnWidths: [String: CGFloat] = [:]
13 @State private var columnWidths: [String: CGFloat] = [:]
14+ @State private var cachedSorted: [LiveTailViewModel.LiveTailEntry] = []
15+ @State private var liveTailSortTask: Task<Void, Never>?
14
16
15 var body: some View {
17 var body: some View {
16 @Bindable var vm = viewModel
18 @Bindable var vm = viewModel
@@ -109,6 +111,7 @@ struct LiveTailView: View {
109 selectedRecord = nil
111 selectedRecord = nil
110 sortColumn = nil
112 sortColumn = nil
111 columnWidths = [:]
113 columnWidths = [:]
114+ cachedSorted = []
112 } label: {
115 } label: {
113 Image(systemName: "trash")
116 Image(systemName: "trash")
114 }
117 }
@@ -259,23 +262,38 @@ struct LiveTailView: View {
259 selectedRecord = nil
262 selectedRecord = nil
260 sortColumn = nil
263 sortColumn = nil
261 columnWidths = [:]
264 columnWidths = [:]
265+ cachedSorted = []
262 }
266 }
263 .onChange(of: viewModel.visibleColumns) { _, newColumns in
267 .onChange(of: viewModel.visibleColumns) { _, newColumns in
264 // Compute widths for any newly visible columns
268 // Compute widths for any newly visible columns
265− let records = viewModel.filteredEntries.map { $0.record }
269+ let records = viewModel.cachedFilteredEntries.map { $0.record }
266 for col in newColumns where columnWidths[col] == nil {
270 for col in newColumns where columnWidths[col] == nil {
267 columnWidths[col] = idealColumnWidth(for: col, records: records)
271 columnWidths[col] = idealColumnWidth(for: col, records: records)
268 }
272 }
269 }
273 }
274+ .onAppear {
275+ rebuildSortedEntries()
276+ }
270 }
277 }
271
278
272− private var sortedEntries: [LiveTailViewModel.LiveTailEntry] {
279+ private func rebuildSortedEntries() {
273− let entries = viewModel.filteredEntries
280+ let entries = viewModel.cachedFilteredEntries
274− guard let sortColumn else { return entries }
281+ guard let col = sortColumn else {
282+ cachedSorted = entries
283+ return
284+ }
285+ liveTailSortTask?.cancel()
286+ let asc = sortAscending
287+ liveTailSortTask = Task {
288+ try? await Task.sleep(for: .milliseconds(150))
289+ guard !Task.isCancelled else { return }
275− return entries.sorted { a, b in
290+ let sorted = entries.sorted { a, b in
276− let aVal = a.record[sortColumn] ?? .null
291+ let aVal = a.record[col] ?? .null
277− let bVal = b.record[sortColumn] ?? .null
292+ let bVal = b.record[col] ?? .null
278− return sortAscending ? aVal < bVal : bVal < aVal
293+ return asc ? aVal < bVal : bVal < aVal
294+ }
295+ guard !Task.isCancelled else { return }
296+ cachedSorted = sorted
279 }
297 }
280 }
298 }
281
299
@@ -284,25 +302,24 @@ struct LiveTailView: View {
284 ScrollView([.horizontal, .vertical]) {
302 ScrollView([.horizontal, .vertical]) {
285 LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) {
303 LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) {
286 Section {
304 Section {
287− let sorted = sortedEntries
288− ForEach(sorted.indices, id: \.self) { index in
305+ ForEach(cachedSorted.indices, id: \.self) { index in
289 LogRowView(
306 LogRowView(
290− record: sorted[index].record,
307+ record: cachedSorted[index].record,
291 columns: viewModel.visibleColumns,
308 columns: viewModel.visibleColumns,
292 columnWidths: columnWidths,
309 columnWidths: columnWidths,
293− isSelected: selectedRecord == sorted[index].record,
310+ isSelected: selectedRecord == cachedSorted[index].record,
294 isAlternate: index % 2 == 1,
311 isAlternate: index % 2 == 1,
295 wrapText: wrapText,
312 wrapText: wrapText,
296 onCellFilter: { column, value, exclude in
313 onCellFilter: { column, value, exclude in
297 viewModel.addColumnFilter(column: column, value: value, exclude: exclude)
314 viewModel.addColumnFilter(column: column, value: value, exclude: exclude)
298 }
315 }
299 )
316 )
300− .id(sorted[index].id)
317+ .id(cachedSorted[index].id)
301 .onTapGesture {
318 .onTapGesture {
302− if selectedRecord == sorted[index].record {
319+ if selectedRecord == cachedSorted[index].record {
303 selectedRecord = nil
320 selectedRecord = nil
304 } else {
321 } else {
305− selectedRecord = sorted[index].record
322+ selectedRecord = cachedSorted[index].record
306 }
323 }
307 }
324 }
308 }
325 }
@@ -312,7 +329,7 @@ struct LiveTailView: View {
312 sortColumn: $sortColumn,
329 sortColumn: $sortColumn,
313 sortAscending: $sortAscending,
330 sortAscending: $sortAscending,
314 columnWidths: $columnWidths,
331 columnWidths: $columnWidths,
315− records: viewModel.filteredEntries.map { $0.record },
332+ records: viewModel.cachedFilteredEntries.map { $0.record },
316 onMoveColumn: { from, to in
333 onMoveColumn: { from, to in
317 viewModel.moveColumn(from, to: to)
334 viewModel.moveColumn(from, to: to)
318 },
335 },
@@ -323,13 +340,20 @@ struct LiveTailView: View {
323 }
340 }
324 }
341 }
325 }
342 }
343+ .onChange(of: viewModel.filteredEntriesGeneration) { _, _ in
344+ rebuildSortedEntries()
345+ }
346+ .onChange(of: sortColumn) { _, _ in
347+ rebuildSortedEntries()
348+ }
349+ .onChange(of: sortAscending) { _, _ in
350+ rebuildSortedEntries()
351+ }
326 .onChange(of: viewModel.entries.count) { _, _ in
352 .onChange(of: viewModel.entries.count) { _, _ in
327 guard autoScroll, sortColumn == nil else { return }
353 guard autoScroll, sortColumn == nil else { return }
328− // When a filter is active, scroll to the last filtered entry;
329− // when no filter, scroll to the absolute last entry.
330 let hasFilters = !viewModel.filterText.isEmpty || !viewModel.columnFilters.isEmpty
354 let hasFilters = !viewModel.filterText.isEmpty || !viewModel.columnFilters.isEmpty
331 let target = hasFilters
355 let target = hasFilters
332− ? viewModel.filteredEntries.last
356+ ? viewModel.cachedFilteredEntries.last
333 : viewModel.entries.last
357 : viewModel.entries.last
334 if let target {
358 if let target {
335 proxy.scrollTo(target.id, anchor: .bottom)
359 proxy.scrollTo(target.id, anchor: .bottom)
ParseableViewer/Views/LogTableView.swift+42−4
@@ -157,6 +157,7 @@ struct LogTableView: View {
157 @State private var selectedIndex: Int?
157 @State private var selectedIndex: Int?
158 @State private var cachedSorted: [LogRecord] = []
158 @State private var cachedSorted: [LogRecord] = []
159 @State private var sortTask: Task<Void, Never>?
159 @State private var sortTask: Task<Void, Never>?
160+ @State private var widthTask: Task<Void, Never>?
160 @State private var columnWidths: [String: CGFloat] = [:]
161 @State private var columnWidths: [String: CGFloat] = [:]
161
162
162 var body: some View {
163 var body: some View {
@@ -229,8 +230,14 @@ struct LogTableView: View {
229 .controlSize(.large)
230 .controlSize(.large)
230 }
231 }
231 }
232 }
232− .onChange(of: records) { _, _ in
233+ .onChange(of: records) { _, newRecords in
234+ widthTask?.cancel()
235+ let cols = columns
236+ widthTask = Task.detached(priority: .userInitiated) {
233− columnWidths = computeColumnWidths(columns: columns, records: records)
237+ let widths = computeColumnWidths(columns: cols, records: newRecords)
238+ guard !Task.isCancelled else { return }
239+ await MainActor.run { columnWidths = widths }
240+ }
234 debouncedRebuildSort()
241 debouncedRebuildSort()
235 }
242 }
236 .onChange(of: sortColumn) { _, _ in debouncedRebuildSort() }
243 .onChange(of: sortColumn) { _, _ in debouncedRebuildSort() }
@@ -247,7 +254,29 @@ struct LogTableView: View {
247 sortTask = Task {
254 sortTask = Task {
248 try? await Task.sleep(for: .milliseconds(150))
255 try? await Task.sleep(for: .milliseconds(150))
249 guard !Task.isCancelled else { return }
256 guard !Task.isCancelled else { return }
250− rebuildSort()
257+ let recs = records
258+ let col = sortColumn
259+ let asc = sortAscending
260+ let sorted: [LogRecord]
261+ if let col {
262+ sorted = await Task.detached(priority: .userInitiated) {
263+ recs.sorted { a, b in
264+ let aVal = a[col] ?? .null
265+ let bVal = b[col] ?? .null
266+ return asc ? aVal < bVal : bVal < aVal
267+ }
268+ }.value
269+ } else {
270+ sorted = recs
271+ }
272+ guard !Task.isCancelled else { return }
273+ cachedSorted = sorted
274+ if let selectedRecord,
275+ let idx = cachedSorted.firstIndex(where: { $0 == selectedRecord }) {
276+ selectedIndex = idx
277+ } else {
278+ selectedIndex = nil
279+ }
251 }
280 }
252 }
281 }
253
282
@@ -323,10 +352,14 @@ struct LogHeaderView: View {
323
352
324 @State private var draggedColumn: String?
353 @State private var draggedColumn: String?
325 @State private var dropTargetColumn: String?
354 @State private var dropTargetColumn: String?
355+ @State private var uniqueValuesCache: [String: [JSONValue]] = [:]
326
356
327 private static let maxFilterValues = 20
357 private static let maxFilterValues = 20
328
358
329 private func uniqueValues(for column: String) -> [JSONValue] {
359 private func uniqueValues(for column: String) -> [JSONValue] {
360+ if let cached = uniqueValuesCache[column] {
361+ return cached
362+ }
330 var seen = Set<JSONValue>()
363 var seen = Set<JSONValue>()
331 var result: [JSONValue] = []
364 var result: [JSONValue] = []
332 for record in records {
365 for record in records {
@@ -335,7 +368,9 @@ struct LogHeaderView: View {
335 result.append(value)
368 result.append(value)
336 }
369 }
337 }
370 }
338− return result.sorted()
371+ let sorted = result.sorted()
372+ uniqueValuesCache[column] = sorted
373+ return sorted
339 }
374 }
340
375
341 private func dropIndicatorAlignment(for targetColumn: String) -> Alignment {
376 private func dropIndicatorAlignment(for targetColumn: String) -> Alignment {
@@ -476,6 +511,9 @@ struct LogHeaderView: View {
476 }
511 }
477 .background(Color(nsColor: .controlBackgroundColor))
512 .background(Color(nsColor: .controlBackgroundColor))
478 .border(Color.secondary.opacity(0.2), width: 0.5)
513 .border(Color.secondary.opacity(0.2), width: 0.5)
514+ .onChange(of: records.count) { _, _ in
515+ uniqueValuesCache = [:]
516+ }
479 }
517 }
480}
518}
481
519
ParseableViewerTests/LiveTailViewModelTests.swift+1−1
@@ -216,7 +216,7 @@ final class LiveTailViewModelTests: XCTestCase {
216 func testFilteredEntriesEmptyFilter() {
216 func testFilteredEntriesEmptyFilter() {
217 let vm = LiveTailViewModel()
217 let vm = LiveTailViewModel()
218 vm.filterText = ""
218 vm.filterText = ""
219− XCTAssertEqual(vm.filteredEntries.count, vm.entries.count)
219+ XCTAssertEqual(vm.cachedFilteredEntries.count, vm.entries.count)
220 }
220 }
221
221
222 func testStopResetsRunningState() {
222 func testStopResetsRunningState() {
ParseableViewerTests/QueryViewModelTests.swift+4−2
@@ -61,7 +61,7 @@ final class QueryViewModelTests: XCTestCase {
61 XCTAssertEqual(vm.filteredResults.count, 2)
61 XCTAssertEqual(vm.filteredResults.count, 2)
62 }
62 }
63
63
64− func testFilteredResultsMatchesContent() {
64+ func testFilteredResultsMatchesContent() async {
65 let vm = QueryViewModel()
65 let vm = QueryViewModel()
66 vm.results = [
66 vm.results = [
67 ["message": .string("hello world")],
67 ["message": .string("hello world")],
@@ -69,16 +69,18 @@ final class QueryViewModelTests: XCTestCase {
69 ["message": .string("Hello there")]
69 ["message": .string("Hello there")]
70 ]
70 ]
71 vm.filterText = "hello"
71 vm.filterText = "hello"
72+ try? await Task.sleep(for: .milliseconds(200))
72 XCTAssertEqual(vm.filteredResults.count, 2) // case-insensitive
73 XCTAssertEqual(vm.filteredResults.count, 2) // case-insensitive
73 }
74 }
74
75
75− func testFilteredResultsNoMatch() {
76+ func testFilteredResultsNoMatch() async {
76 let vm = QueryViewModel()
77 let vm = QueryViewModel()
77 vm.results = [
78 vm.results = [
78 ["message": .string("hello")],
79 ["message": .string("hello")],
79 ["message": .string("world")]
80 ["message": .string("world")]
80 ]
81 ]
81 vm.filterText = "xyz"
82 vm.filterText = "xyz"
83+ try? await Task.sleep(for: .milliseconds(200))
82 XCTAssertEqual(vm.filteredResults.count, 0)
84 XCTAssertEqual(vm.filteredResults.count, 0)
83 }
85 }
84
86