2@testable import ParseableViewer
5final class LiveTailViewModelTests: XCTestCase {
6 // MARK: - Fingerprinting
8 func testFingerprintDeterministic() {
9 let record: LogRecord = [
10 "message": .string("hello"),
11 "level": .string("info")
13 let fp1 = LiveTailViewModel.fingerprint(for: record)
14 let fp2 = LiveTailViewModel.fingerprint(for: record)
15 XCTAssertEqual(fp1, fp2)
18 func testFingerprintDifferentForDifferentRecords() {
19 let record1: LogRecord = ["message": .string("hello")]
20 let record2: LogRecord = ["message": .string("world")]
21 let fp1 = LiveTailViewModel.fingerprint(for: record1)
22 let fp2 = LiveTailViewModel.fingerprint(for: record2)
23 XCTAssertNotEqual(fp1, fp2)
26 func testFingerprintOrderIndependent() {
27 // Records with same keys/values in different insertion order should produce same fingerprint
28 // since fingerprint sorts keys
29 let record1: LogRecord = ["a": .string("1"), "b": .string("2")]
30 let record2: LogRecord = ["b": .string("2"), "a": .string("1")]
31 let fp1 = LiveTailViewModel.fingerprint(for: record1)
32 let fp2 = LiveTailViewModel.fingerprint(for: record2)
33 XCTAssertEqual(fp1, fp2)
36 func testFingerprintEmptyRecord() {
37 let record: LogRecord = [:]
38 let fp = LiveTailViewModel.fingerprint(for: record)
39 XCTAssertFalse(fp.isEmpty)
42 func testFingerprintDistinguishesKeyFromValue() {
43 // Ensure "a"="b" and "ab"="" produce different fingerprints
44 let record1: LogRecord = ["a": .string("b")]
45 let record2: LogRecord = ["ab": .string("")]
46 let fp1 = LiveTailViewModel.fingerprint(for: record1)
47 let fp2 = LiveTailViewModel.fingerprint(for: record2)
48 XCTAssertNotEqual(fp1, fp2)
51 func testFingerprintHandlesNullValues() {
52 let record: LogRecord = ["key": .null]
53 let fp = LiveTailViewModel.fingerprint(for: record)
54 XCTAssertFalse(fp.isEmpty)
57 func testFingerprintHandlesNestedValues() {
58 let record: LogRecord = ["data": .object(["nested": .string("value")])]
59 let fp = LiveTailViewModel.fingerprint(for: record)
60 XCTAssertFalse(fp.isEmpty)
63 // MARK: - Timestamp parsing
65 func testParseTimestampISO8601WithFractionalSeconds() {
66 let record: LogRecord = ["p_timestamp": .string("2024-06-15T10:30:45.123Z")]
67 let date = LiveTailViewModel.parseTimestamp(from: record)
71 func testParseTimestampISO8601Basic() {
72 let record: LogRecord = ["p_timestamp": .string("2024-06-15T10:30:45Z")]
73 let date = LiveTailViewModel.parseTimestamp(from: record)
77 func testParseTimestampFallbackFields() {
79 let r1: LogRecord = ["timestamp": .string("2024-06-15T10:30:45Z")]
80 XCTAssertNotNil(LiveTailViewModel.parseTimestamp(from: r1))
83 let r2: LogRecord = ["time": .string("2024-06-15T10:30:45Z")]
84 XCTAssertNotNil(LiveTailViewModel.parseTimestamp(from: r2))
87 let r3: LogRecord = ["@timestamp": .string("2024-06-15T10:30:45Z")]
88 XCTAssertNotNil(LiveTailViewModel.parseTimestamp(from: r3))
91 func testParseTimestampPriorityOrder() {
92 // p_timestamp should take priority
93 let record: LogRecord = [
94 "p_timestamp": .string("2024-01-01T00:00:00Z"),
95 "timestamp": .string("2024-12-31T23:59:59Z")
97 let date = LiveTailViewModel.parseTimestamp(from: record)
100 let calendar = Calendar.current
101 let components = calendar.dateComponents(in: TimeZone(identifier: "UTC")!, from: date!)
102 XCTAssertEqual(components.year, 2024)
103 XCTAssertEqual(components.month, 1)
106 func testParseTimestampNoTimestampField() {
107 let record: LogRecord = ["message": .string("no timestamp")]
108 let date = LiveTailViewModel.parseTimestamp(from: record)
112 func testParseTimestampNonStringValue() {
113 let record: LogRecord = ["p_timestamp": .int(1718446245)]
114 let date = LiveTailViewModel.parseTimestamp(from: record)
118 func testParseTimestampInvalidString() {
119 let record: LogRecord = ["p_timestamp": .string("not-a-date")]
120 let date = LiveTailViewModel.parseTimestamp(from: record)
124 // MARK: - Summary building
126 func testBuildSummaryWithLevelAndMessage() {
127 let record: LogRecord = [
128 "level": .string("info"),
129 "message": .string("Server started")
131 let summary = LiveTailViewModel.buildSummary(from: record)
132 XCTAssertTrue(summary.contains("[info]"))
133 XCTAssertTrue(summary.contains("Server started"))
136 func testBuildSummaryAlternateFieldNames() {
137 let record: LogRecord = [
138 "severity": .string("warn"),
139 "msg": .string("Disk almost full")
141 let summary = LiveTailViewModel.buildSummary(from: record)
142 XCTAssertTrue(summary.contains("[warn]"))
143 XCTAssertTrue(summary.contains("Disk almost full"))
146 func testBuildSummaryFallbackToScalarFields() {
147 let record: LogRecord = [
149 "path": .string("/api/health")
151 let summary = LiveTailViewModel.buildSummary(from: record)
152 XCTAssertFalse(summary.isEmpty)
155 func testBuildSummaryEmptyRecord() {
156 let record: LogRecord = [:]
157 let summary = LiveTailViewModel.buildSummary(from: record)
158 XCTAssertEqual(summary, "")
161 // MARK: - ViewModel state
163 func testInitialState() {
164 let vm = LiveTailViewModel()
165 XCTAssertFalse(vm.isRunning)
166 XCTAssertFalse(vm.isPaused)
167 XCTAssertTrue(vm.entries.isEmpty)
168 XCTAssertEqual(vm.droppedCount, 0)
169 XCTAssertNil(vm.lastPollTime)
170 XCTAssertNil(vm.errorMessage)
173 func testStartWithoutClientIsNoOp() {
174 let vm = LiveTailViewModel()
175 vm.start(client: nil, stream: "test")
176 XCTAssertFalse(vm.isRunning)
179 func testStartWithoutStreamIsNoOp() {
180 let connection = ServerConnection(name: "test", url: "https://example.com", username: "u", password: "p")
181 let client = try? ParseableClient(connection: connection)
182 let vm = LiveTailViewModel()
183 vm.start(client: client, stream: nil)
184 XCTAssertFalse(vm.isRunning)
187 func testTogglePause() {
188 let vm = LiveTailViewModel()
189 XCTAssertFalse(vm.isPaused)
191 XCTAssertTrue(vm.isPaused)
193 XCTAssertFalse(vm.isPaused)
196 func testClearResetsState() {
197 let vm = LiveTailViewModel()
200 XCTAssertTrue(vm.entries.isEmpty)
201 XCTAssertEqual(vm.droppedCount, 0)
204 func testFilteredEntriesEmptyFilter() {
205 let vm = LiveTailViewModel()
207 XCTAssertEqual(vm.cachedFilteredEntries.count, vm.entries.count)
210 func testStopResetsRunningState() {
211 let connection = ServerConnection(name: "test", url: "https://example.com", username: "u", password: "p")
212 let client = try? ParseableClient(connection: connection)
213 let vm = LiveTailViewModel()
214 vm.start(client: client, stream: "test-stream")
215 XCTAssertTrue(vm.isRunning)
217 XCTAssertFalse(vm.isRunning)
218 XCTAssertFalse(vm.isPaused)
221 func testStartTwiceIsNoOp() {
222 let connection = ServerConnection(name: "test", url: "https://example.com", username: "u", password: "p")
223 let client = try? ParseableClient(connection: connection)
224 let vm = LiveTailViewModel()
225 vm.start(client: client, stream: "test-stream")
226 XCTAssertTrue(vm.isRunning)
227 // Starting again while running should be a no-op
228 vm.start(client: client, stream: "other-stream")
229 XCTAssertTrue(vm.isRunning)
233 func testDisplayedCountMatchesFilteredEntries() {
234 let vm = LiveTailViewModel()
235 XCTAssertEqual(vm.displayedCount, 0)
236 XCTAssertEqual(vm.entryCount, 0)
239 func testClearResetsFingerprintSet() {
240 let vm = LiveTailViewModel()
241 // After clear, entries and dropped count should reset
244 XCTAssertTrue(vm.entries.isEmpty)
245 XCTAssertEqual(vm.droppedCount, 0)
248 func testStopInvalidatesState() {
249 let vm = LiveTailViewModel()
250 // Simulate paused state
252 XCTAssertTrue(vm.isPaused)
253 // Stop should reset both running and paused
255 XCTAssertFalse(vm.isRunning)
256 XCTAssertFalse(vm.isPaused)
259 func testBuildSummaryWithLogField() {
260 let record: LogRecord = [
261 "log_level": .string("debug"),
262 "log": .string("Processing request")
264 let summary = LiveTailViewModel.buildSummary(from: record)
265 XCTAssertTrue(summary.contains("[debug]"))
266 XCTAssertTrue(summary.contains("Processing request"))
269 // MARK: - Column Management
271 func testInitialColumnState() {
272 let vm = LiveTailViewModel()
273 XCTAssertTrue(vm.columns.isEmpty)
274 XCTAssertTrue(vm.columnOrder.isEmpty)
275 XCTAssertTrue(vm.hiddenColumns.isEmpty)
276 XCTAssertTrue(vm.visibleColumns.isEmpty)
279 func testVisibleColumnsExcludesHidden() {
280 let vm = LiveTailViewModel()
281 vm.columnOrder = ["a", "b", "c"]
282 vm.hiddenColumns = ["b"]
283 XCTAssertEqual(vm.visibleColumns, ["a", "c"])
286 func testToggleColumnVisibilityHide() {
287 let vm = LiveTailViewModel()
288 vm.columns = ["a", "b", "c"]
289 vm.columnOrder = ["a", "b", "c"]
290 vm.hiddenColumns = []
292 vm.toggleColumnVisibility("b")
293 XCTAssertTrue(vm.hiddenColumns.contains("b"))
294 XCTAssertEqual(vm.visibleColumns, ["a", "c"])
297 func testToggleColumnVisibilityShow() {
298 let vm = LiveTailViewModel()
299 vm.columns = ["a", "b", "c"]
300 vm.columnOrder = ["a", "b", "c"]
301 vm.hiddenColumns = ["b"]
303 vm.toggleColumnVisibility("b")
304 XCTAssertFalse(vm.hiddenColumns.contains("b"))
305 XCTAssertEqual(vm.visibleColumns, ["a", "b", "c"])
308 func testCannotHideLastVisibleColumn() {
309 let vm = LiveTailViewModel()
311 vm.columnOrder = ["a"]
312 vm.hiddenColumns = []
314 vm.toggleColumnVisibility("a")
315 // Should not hide the last visible column
316 XCTAssertFalse(vm.hiddenColumns.contains("a"))
317 XCTAssertEqual(vm.visibleColumns, ["a"])
320 func testShowAllColumns() {
321 let vm = LiveTailViewModel()
322 vm.columns = ["a", "b", "c"]
323 vm.columnOrder = ["a", "b", "c"]
324 vm.hiddenColumns = ["a", "c"]
327 XCTAssertTrue(vm.hiddenColumns.isEmpty)
328 XCTAssertEqual(vm.visibleColumns, ["a", "b", "c"])
331 func testMoveColumnByName() {
332 let vm = LiveTailViewModel()
333 vm.columns = ["a", "b", "c"]
334 vm.columnOrder = ["a", "b", "c"]
336 vm.moveColumn("c", to: "a")
337 XCTAssertEqual(vm.columnOrder, ["c", "a", "b"])
340 func testMoveColumnSamePositionIsNoOp() {
341 let vm = LiveTailViewModel()
342 vm.columns = ["a", "b", "c"]
343 vm.columnOrder = ["a", "b", "c"]
345 vm.moveColumn("a", to: "a")
346 XCTAssertEqual(vm.columnOrder, ["a", "b", "c"])
349 func testResetColumnConfig() {
350 let vm = LiveTailViewModel()
351 vm.columns = ["a", "b", "c"]
352 vm.columnOrder = ["c", "b", "a"]
353 vm.hiddenColumns = ["b"]
355 vm.resetColumnConfig()
356 XCTAssertEqual(vm.columnOrder, ["a", "b", "c"])
357 XCTAssertTrue(vm.hiddenColumns.isEmpty)
360 func testClearResetsColumns() {
361 let vm = LiveTailViewModel()
362 vm.columns = ["a", "b"]
363 vm.columnOrder = ["a", "b"]
364 vm.hiddenColumns = ["b"]
367 XCTAssertTrue(vm.columns.isEmpty)
368 XCTAssertTrue(vm.columnOrder.isEmpty)
369 XCTAssertTrue(vm.hiddenColumns.isEmpty)
372 func testMoveColumnByIndexSet() {
373 let vm = LiveTailViewModel()
374 vm.columns = ["a", "b", "c"]
375 vm.columnOrder = ["a", "b", "c"]
377 // Move "a" (index 0) to after "c" (index 3, past-the-end insertion)
378 vm.moveColumn(from: IndexSet(integer: 0), to: 3)
379 XCTAssertEqual(vm.columnOrder, ["b", "c", "a"])
382 // MARK: - Column Filters
384 func testAddColumnFilter() {
385 let vm = LiveTailViewModel()
386 vm.addColumnFilter(column: "level", value: .string("error"), exclude: false)
387 XCTAssertEqual(vm.columnFilters.count, 1)
388 XCTAssertEqual(vm.columnFilters[0].column, "level")
389 XCTAssertEqual(vm.columnFilters[0].value, .string("error"))
390 XCTAssertFalse(vm.columnFilters[0].exclude)
393 func testAddColumnFilterExclude() {
394 let vm = LiveTailViewModel()
395 vm.addColumnFilter(column: "level", value: .string("debug"), exclude: true)
396 XCTAssertEqual(vm.columnFilters.count, 1)
397 XCTAssertTrue(vm.columnFilters[0].exclude)
400 func testAddDuplicateFilterReplacesExisting() {
401 let vm = LiveTailViewModel()
402 vm.addColumnFilter(column: "level", value: .string("error"), exclude: false)
403 vm.addColumnFilter(column: "level", value: .string("error"), exclude: false)
404 XCTAssertEqual(vm.columnFilters.count, 1)
407 func testAddFilterDifferentValueIsAdditive() {
408 let vm = LiveTailViewModel()
409 vm.addColumnFilter(column: "level", value: .string("error"), exclude: false)
410 vm.addColumnFilter(column: "level", value: .string("warn"), exclude: false)
411 XCTAssertEqual(vm.columnFilters.count, 2)
414 func testRemoveColumnFilter() {
415 let vm = LiveTailViewModel()
416 vm.addColumnFilter(column: "level", value: .string("error"), exclude: false)
417 let filter = vm.columnFilters[0]
418 vm.removeColumnFilter(filter)
419 XCTAssertTrue(vm.columnFilters.isEmpty)
422 func testClearColumnFilters() {
423 let vm = LiveTailViewModel()
424 vm.addColumnFilter(column: "level", value: .string("error"), exclude: false)
425 vm.addColumnFilter(column: "status", value: .int(500), exclude: true)
426 vm.clearColumnFilters()
427 XCTAssertTrue(vm.columnFilters.isEmpty)
430 func testClearResetsColumnFilters() {
431 let vm = LiveTailViewModel()
432 vm.addColumnFilter(column: "level", value: .string("error"), exclude: false)
434 XCTAssertTrue(vm.columnFilters.isEmpty)
437 func testColumnFilterDisplayLabelInclude() {
438 let filter = LiveTailViewModel.ColumnFilter(column: "level", value: .string("error"), exclude: false)
439 XCTAssertEqual(filter.displayLabel, "level = error")
442 func testColumnFilterDisplayLabelExclude() {
443 let filter = LiveTailViewModel.ColumnFilter(column: "level", value: .string("debug"), exclude: true)
444 XCTAssertEqual(filter.displayLabel, "level \u{2260} debug")
447 func testColumnFilterDisplayLabelNull() {
448 let filter = LiveTailViewModel.ColumnFilter(column: "field", value: nil, exclude: false)
449 XCTAssertEqual(filter.displayLabel, "field = null")
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list