Draw the process list with NSTableView instead of SwiftUI's Table
On macOS 26 SwiftUI's Table retained AutoLayout and view-graph objects on every data update, on top of an NSHostingView per cell: tens of MB an hour while the window was visible. Measured side by side (6 min, window visible, scrolling), heap growth fell from ~600 KB/min to ~35 KB/min and the starting footprint from 67 MB to 49 MB. Cells are reused AppKit views and an update rewrites only the changed fields of rows on screen. Column widths and order are remembered; the context menu, double-click, Delete key and sort directions match the old table. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
267
TopManagerTests/ProcessTableTests.swift
Normal file
267
TopManagerTests/ProcessTableTests.swift
Normal file
@@ -0,0 +1,267 @@
|
||||
import XCTest
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
@testable import TopManager
|
||||
|
||||
final class ProcessTableTests: XCTestCase {
|
||||
|
||||
private func item(pid: pid_t = 100, name: String = "proc", user: String = "ariel",
|
||||
cpu: Double = 0, cpuTotal: Double = 0, energy: Double = 0,
|
||||
memory: Int64 = 1_000_000, resident: Int64 = 2_000_000, compressed: Int64 = 0,
|
||||
diskRead: Double = 0, state: ProcessState = .sleeping, icon: NSImage? = nil,
|
||||
start: Date = Date(timeIntervalSince1970: 1_000)) -> ProcessItem {
|
||||
ProcessItem(pid: pid, name: name, user: user, cpuUsage: cpu, cpuUsageTotal: cpuTotal,
|
||||
memoryUsage: memory, threadCount: 4, state: state, icon: icon, parentPid: 1,
|
||||
startTime: start, diskReadRate: diskRead, energyImpact: energy,
|
||||
residentMemory: resident, compressedMemory: compressed)
|
||||
}
|
||||
|
||||
// MARK: Columns
|
||||
|
||||
func testColumnsInDisplayOrder() {
|
||||
XCTAssertEqual(ProcessColumn.allCases.map(\.title),
|
||||
["Name", "PID", "CPU/Core", "CPU/Total", "Energy", "Memory", "RAM",
|
||||
"Compressed", "Disk I/O", "Threads", "User", "State"])
|
||||
}
|
||||
|
||||
func testEveryColumnSortsByItsOwnKey() {
|
||||
for column in ProcessColumn.allCases {
|
||||
XCTAssertEqual(column.sortColumn.rawValue, column.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
func testTextColumnsSortAscendingFirstFiguresDescending() {
|
||||
XCTAssertTrue(ProcessColumn.name.sortsAscendingFirst)
|
||||
XCTAssertTrue(ProcessColumn.user.sortsAscendingFirst)
|
||||
XCTAssertFalse(ProcessColumn.cpu.sortsAscendingFirst)
|
||||
XCTAssertFalse(ProcessColumn.memory.sortsAscendingFirst)
|
||||
XCTAssertFalse(ProcessColumn.compressed.sortsAscendingFirst)
|
||||
}
|
||||
|
||||
func testHeaderClickFlipsCurrentColumnAndStartsOthersInNaturalDirection() {
|
||||
XCTAssertTrue(ProcessColumn.cpu.sortAfterClick(current: .cpu, ascending: false) == (.cpu, true))
|
||||
XCTAssertTrue(ProcessColumn.cpu.sortAfterClick(current: .cpu, ascending: true) == (.cpu, false))
|
||||
XCTAssertTrue(ProcessColumn.memory.sortAfterClick(current: .name, ascending: true) == (.memory, false))
|
||||
XCTAssertTrue(ProcessColumn.resident.sortAfterClick(current: .memory, ascending: false) == (.resident, false))
|
||||
XCTAssertTrue(ProcessColumn.name.sortAfterClick(current: .cpu, ascending: false) == (.name, true))
|
||||
}
|
||||
|
||||
// MARK: Cell content
|
||||
|
||||
func testCPUToneThresholdsMatchPreviousTable() {
|
||||
XCTAssertEqual(ProcessColumn.cpu.content(for: item(cpu: 20)).tone, .primary)
|
||||
XCTAssertEqual(ProcessColumn.cpu.content(for: item(cpu: 20.1)).tone, .yellow)
|
||||
XCTAssertEqual(ProcessColumn.cpu.content(for: item(cpu: 50.1)).tone, .orange)
|
||||
XCTAssertEqual(ProcessColumn.cpu.content(for: item(cpu: 80.1)).tone, .red)
|
||||
XCTAssertEqual(ProcessColumn.cpuTotal.content(for: item(cpuTotal: 5.5)).tone, .orange)
|
||||
XCTAssertEqual(ProcessColumn.energy.content(for: item(energy: 8.5)).tone, .yellow)
|
||||
}
|
||||
|
||||
func testFormatsFigures() {
|
||||
let p = item(pid: 89435, cpu: 25.54, cpuTotal: 3.184, energy: 25.6)
|
||||
XCTAssertEqual(ProcessColumn.pid.content(for: p).text, "89435")
|
||||
XCTAssertEqual(ProcessColumn.cpu.content(for: p).text, "25.5%")
|
||||
XCTAssertEqual(ProcessColumn.cpuTotal.content(for: p).text, "3.18%")
|
||||
XCTAssertEqual(ProcessColumn.energy.content(for: p).text, "25.6")
|
||||
XCTAssertEqual(ProcessColumn.memory.content(for: p).text, formatBytes(Int64(1_000_000)))
|
||||
XCTAssertEqual(ProcessColumn.resident.content(for: p).text, formatBytes(Int64(2_000_000)))
|
||||
XCTAssertTrue(ProcessColumn.memory.content(for: p).monospacedDigits)
|
||||
XCTAssertFalse(ProcessColumn.user.content(for: p).monospacedDigits)
|
||||
}
|
||||
|
||||
func testZeroCompressedAndIdleDiskShowSecondaryDash() {
|
||||
for column in [ProcessColumn.compressed, .disk] {
|
||||
let content = column.content(for: item())
|
||||
XCTAssertEqual(content.text, "—")
|
||||
XCTAssertEqual(content.tone, .secondary)
|
||||
}
|
||||
XCTAssertEqual(ProcessColumn.compressed.content(for: item(compressed: 5_000_000)).text,
|
||||
formatBytes(Int64(5_000_000)))
|
||||
XCTAssertNotEqual(ProcessColumn.disk.content(for: item(diskRead: 2048)).text, "—")
|
||||
}
|
||||
|
||||
func testNameShowsAppIconOrPlaceholderSymbol() {
|
||||
let icon = NSImage(size: NSSize(width: 16, height: 16))
|
||||
XCTAssertEqual(ProcessColumn.name.content(for: item(icon: icon)).leading, .icon(icon))
|
||||
XCTAssertEqual(ProcessColumn.name.content(for: item()).leading, .symbol("app.dashed", .secondary))
|
||||
// Icons compare by identity, so an equal-looking copy still counts as a change.
|
||||
XCTAssertNotEqual(ProcessCellContent.Leading.icon(icon), .icon(NSImage(size: icon.size)))
|
||||
}
|
||||
|
||||
func testStateShowsTintedSymbol() {
|
||||
let running = ProcessColumn.state.content(for: item(state: .running))
|
||||
XCTAssertEqual(running.text, "Running")
|
||||
XCTAssertEqual(running.leading, .symbol(ProcessState.running.symbol, .green))
|
||||
XCTAssertEqual(ProcessColumn.tone(for: .zombie), .red)
|
||||
XCTAssertEqual(ProcessColumn.tone(for: .sleeping), .secondary)
|
||||
}
|
||||
|
||||
// MARK: Update detection
|
||||
|
||||
func testRowsDifferDetectsValueOrderAndIdentityChanges() {
|
||||
let a = item(pid: 1), b = item(pid: 2)
|
||||
let differ = ProcessTableView.Coordinator.rowsDiffer
|
||||
XCTAssertFalse(differ([a, b], [a, b]))
|
||||
XCTAssertTrue(differ([a, b], [b, a])) // re-sorted
|
||||
XCTAssertTrue(differ([a, b], [a])) // process exited
|
||||
XCTAssertTrue(differ([a, b], [a, item(pid: 2, cpu: 9)])) // figure changed
|
||||
XCTAssertTrue(differ([a, b], [a, item(pid: 2, name: "new")])) // PID reused
|
||||
XCTAssertTrue(differ([a, b], [a, item(pid: 2, start: Date(timeIntervalSince1970: 2_000))]))
|
||||
}
|
||||
}
|
||||
|
||||
final class ProcessFilterSortTests: XCTestCase {
|
||||
|
||||
private func p(_ pid: pid_t, _ name: String, cpu: Double = 0, memory: Int64 = 0, compressed: Int64 = 0) -> ProcessItem {
|
||||
ProcessItem(pid: pid, name: name, user: "ariel", cpuUsage: cpu, cpuUsageTotal: cpu / 8, memoryUsage: memory,
|
||||
threadCount: 1, state: .sleeping, icon: nil, parentPid: 1, startTime: nil,
|
||||
compressedMemory: compressed)
|
||||
}
|
||||
|
||||
private lazy var sample = [
|
||||
p(1, "launchd"),
|
||||
p(300, "Finder", cpu: 12, memory: 200_000_000, compressed: 50_000_000),
|
||||
p(200, "Teams", cpu: 3, memory: 900_000_000, compressed: 800_000_000),
|
||||
p(400, "zsh", cpu: 40, memory: 5_000_000),
|
||||
]
|
||||
|
||||
private func pids(_ column: ProcessSortColumn, ascending: Bool, search: String = "") -> [pid_t] {
|
||||
ProcessView.filterAndSort(sample, search: search, by: column, ascending: ascending).map(\.pid)
|
||||
}
|
||||
|
||||
func testFigureColumnsSortBothWays() {
|
||||
XCTAssertEqual(pids(.compressed, ascending: false), [200, 300, 1, 400]) // ties fall back to PID
|
||||
XCTAssertEqual(pids(.compressed, ascending: true), [1, 400, 300, 200])
|
||||
XCTAssertEqual(pids(.memory, ascending: false), [200, 300, 400, 1])
|
||||
XCTAssertEqual(pids(.cpu, ascending: false), [400, 300, 200, 1])
|
||||
}
|
||||
|
||||
func testNameSortIsCaseInsensitive() {
|
||||
XCTAssertEqual(pids(.name, ascending: true), [300, 1, 200, 400])
|
||||
XCTAssertEqual(pids(.name, ascending: false), [400, 200, 1, 300])
|
||||
}
|
||||
|
||||
func testSearchMatchesNameOrPid() {
|
||||
XCTAssertEqual(pids(.pid, ascending: true, search: "fin"), [300])
|
||||
XCTAssertEqual(pids(.pid, ascending: true, search: "40"), [400])
|
||||
XCTAssertEqual(pids(.pid, ascending: true, search: ""), [1, 200, 300, 400])
|
||||
}
|
||||
}
|
||||
|
||||
/// Drives the real coordinator against a real (window-less) NSTableView.
|
||||
final class ProcessTableCoordinatorTests: XCTestCase {
|
||||
|
||||
private final class Harness {
|
||||
var selection: Set<pid_t> = []
|
||||
var sort: (ProcessSortColumn, Bool) = (.cpu, false)
|
||||
var opened: [pid_t] = []
|
||||
var terminated: [pid_t] = []
|
||||
var deleteKeyPresses = 0
|
||||
let table = ProcessNSTableView()
|
||||
var coordinator: ProcessTableView.Coordinator!
|
||||
|
||||
func view(_ rows: [ProcessItem]) -> ProcessTableView {
|
||||
ProcessTableView(
|
||||
processes: rows,
|
||||
selection: Binding(get: { self.selection }, set: { self.selection = $0 }),
|
||||
sortColumn: sort.0, sortAscending: sort.1,
|
||||
onSort: { self.sort = ($0, $1) },
|
||||
actions: ProcessTableActions(open: { self.opened.append($0.pid) },
|
||||
terminate: { self.terminated.append($0.pid) },
|
||||
forceKill: { _ in }, suspend: { _ in }, resume: { _ in },
|
||||
deleteKey: { self.deleteKeyPresses += 1 }))
|
||||
}
|
||||
|
||||
/// Mirrors makeNSView + updateNSView.
|
||||
func show(_ rows: [ProcessItem]) {
|
||||
let view = view(rows)
|
||||
if coordinator == nil {
|
||||
coordinator = view.makeCoordinator()
|
||||
for column in ProcessColumn.allCases {
|
||||
table.addTableColumn(NSTableColumn(identifier: NSUserInterfaceItemIdentifier(column.rawValue)))
|
||||
}
|
||||
table.dataSource = coordinator
|
||||
table.delegate = coordinator
|
||||
table.onDelete = { self.deleteKeyPresses += 1 }
|
||||
coordinator.tableView = table
|
||||
}
|
||||
coordinator.parent = view
|
||||
coordinator.update()
|
||||
}
|
||||
}
|
||||
|
||||
private func item(_ pid: pid_t, _ name: String) -> ProcessItem {
|
||||
ProcessItem(pid: pid, name: name, user: "ariel", cpuUsage: 0, cpuUsageTotal: 0, memoryUsage: 0,
|
||||
threadCount: 1, state: .sleeping, icon: nil, parentPid: 1, startTime: nil)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testSelectionFollowsTheProcessWhenRowsReorder() {
|
||||
let h = Harness()
|
||||
let a = item(10, "a"), b = item(20, "b"), c = item(30, "c")
|
||||
h.show([a, b, c])
|
||||
XCTAssertEqual(h.table.numberOfRows, 3, "first load must not double the row count")
|
||||
|
||||
h.table.selectRowIndexes([1], byExtendingSelection: false) // user clicks "b"
|
||||
XCTAssertEqual(h.selection, [20])
|
||||
|
||||
h.show([c, a, b]) // re-sorted by a refresh
|
||||
XCTAssertEqual(h.table.selectedRowIndexes, [2])
|
||||
XCTAssertEqual(h.selection, [20], "syncing the table must not write back into the binding")
|
||||
|
||||
h.show([c, a]) // "b" exited
|
||||
XCTAssertEqual(h.table.numberOfRows, 2)
|
||||
XCTAssertTrue(h.table.selectedRowIndexes.isEmpty)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testHeaderClickReportsNextSort() {
|
||||
let h = Harness()
|
||||
h.show([item(1, "a")])
|
||||
let memory = h.table.tableColumn(withIdentifier: NSUserInterfaceItemIdentifier("memory"))!
|
||||
h.coordinator.tableView(h.table, didClick: memory)
|
||||
XCTAssertTrue(h.sort == (.memory, false))
|
||||
h.show([item(1, "a")]) // SwiftUI re-renders with the new sort
|
||||
h.coordinator.tableView(h.table, didClick: memory)
|
||||
XCTAssertTrue(h.sort == (.memory, true))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testContextMenuUsesSelectedRowWithoutRightClick() throws {
|
||||
let h = Harness()
|
||||
h.show([item(10, "a"), item(20, "b")])
|
||||
|
||||
let empty = NSMenu()
|
||||
h.coordinator.menuNeedsUpdate(empty)
|
||||
XCTAssertTrue(empty.items.isEmpty, "no click and no selection: nothing to act on")
|
||||
|
||||
h.table.selectRowIndexes([1], byExtendingSelection: false)
|
||||
let menu = NSMenu()
|
||||
h.coordinator.menuNeedsUpdate(menu)
|
||||
XCTAssertEqual(menu.items.filter { !$0.isSeparatorItem }.map(\.title),
|
||||
["Get Info (⌘I)", "Terminate (⌫)", "Force Kill (⌘⌫)", "Suspend", "Resume", "Copy PID"])
|
||||
|
||||
for title in ["Get Info (⌘I)", "Terminate (⌫)"] {
|
||||
let entry = try XCTUnwrap(menu.items.first { $0.title == title })
|
||||
_ = (entry.target as? NSObject)?.perform(entry.action, with: entry)
|
||||
}
|
||||
XCTAssertEqual(h.opened, [20])
|
||||
XCTAssertEqual(h.terminated, [20])
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testDeleteKeyTriggersTerminateButCommandDeleteDoesNot() throws {
|
||||
let h = Harness()
|
||||
h.show([item(1, "a")])
|
||||
func key(_ code: UInt16, _ flags: NSEvent.ModifierFlags) throws -> NSEvent {
|
||||
try XCTUnwrap(NSEvent.keyEvent(with: .keyDown, location: .zero, modifierFlags: flags, timestamp: 0,
|
||||
windowNumber: 0, context: nil, characters: "\u{8}",
|
||||
charactersIgnoringModifiers: "\u{8}", isARepeat: false, keyCode: code))
|
||||
}
|
||||
h.table.keyDown(with: try key(51, []))
|
||||
h.table.keyDown(with: try key(117, [.function])) // forward delete
|
||||
XCTAssertEqual(h.deleteKeyPresses, 2)
|
||||
h.table.keyDown(with: try key(51, [.command])) // ⌘⌫ belongs to Force Kill
|
||||
XCTAssertEqual(h.deleteKeyPresses, 2)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user