Skip to content

Commit 87ee7ac

Browse files
DaxxSecDaxxSecclaude
authored
feat(ui): multi-line VM card + network-mode cycle restored (#15)
Two user-reported asks land together here because they're tightly coupled (the cycle action needs space to live on the card): 1. VM rows are now multi-line cards The previous column-based table (Name | Status | OS | CPU | RAM | Disk | LastUsed | Traffic) felt cramped and broke the mockup's intended density when window widths shrank. New 62pt-tall card: Row 1: <name> <status pill> Row 2: <os> · <cores · GB ram> · <disk size> Row 3: [⇄ network chip ▾] ↓/↑ rate pkts ▁▄▇ 2. Network-mode cycle (lost when the right panel went away) The mockup's right-side panel had a click-to-cycle network mode target. That capability disappeared when the panel was removed in the redesign. Restored here as the row-3 network chip — click it to cycle: NAT → Virtual (isolated) → Router → NAT Cycling a running VM is blocked (Apple's Virtualization framework attaches the network device once at VM-start; it can't hot-swap). Stop-then-cycle is the correct workflow and the click handler surfaces the standard alert with that explanation when the user tries it on a live VM. Implementation ============== - `VMCardCellView`: NSTableCellView subclass with the three-row layout. Right-edge subviews (status pill, sparkline, packets label) anchor to bounds.maxX via layout() so they track on table resize; left-edge labels reflow into the available width. - AI Sandbox tab uses the same card with row 3 collapsed to a static badge — TEMPLATE (orange) or SESSION (green) — instead of a clickable network chip. - `switchTableToCardMode(_:)` removes the XIB-defined columns at runtime and replaces them with a single full-width VMCardColumn. Idempotent — safe to call on every awakeFromNib. - `VMManager.cycleNetworkMode(_:)` updates the in-memory list + persists to metadata.json. Pure step function `nextNetworkConfig(after:)` is testable without singleton state. - `VMError.networkModeChangeWhileRunning` carries the user-facing explanation for why a running VM can't cycle (Apple framework limitation, not our choice). A guest VM (virtual-mode, routerVMId set) short-circuits straight to NAT on the next cycle rather than getting dragged through the canonical path with a stale routerVMId — keeps the cycle clean. Tests ===== New `VMCardCellAndCycleTests` — **14 passing**: - Cycle: NAT → Virtual → Router → NAT loops correctly - Guest VM cleanly drops to NAT, clearing the routerVMId - Network chip text reflects each mode (NAT / ROUTER / GUEST / ISOLATED) - Status pill text matches each VMStatus - Packet count formatter uses comma grouping (0 / 42 / 1,234 / 1,234,567) - Bps formatter crosses B → kB → MB → GB thresholds - Cell survives configure() with every status without crashing Full suite: 264/264 green (one pre-existing flaky network test skipped as before). Co-authored-by: DaxxSec <dax@example.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a9d2bbb commit 87ee7ac

5 files changed

Lines changed: 715 additions & 14 deletions

File tree

SecVF.xcodeproj/project.pbxproj

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
CC1111CC11111111CC111CC1 /* VMConfigurationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1111DD11111111DD111DD1 /* VMConfigurationTests.swift */; };
3434
SPKT002SPKT002SPKT002002 /* SparklineViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = SPKT001SPKT001SPKT001001 /* SparklineViewTests.swift */; };
3535
TUIT002TUIT002TUIT002002 /* TacticalUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = TUIT001TUIT001TUIT001001 /* TacticalUITests.swift */; };
36+
VCCT002VCCT002VCCT002002 /* VMCardCellAndCycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = VCCT001VCCT001VCCT001001 /* VMCardCellAndCycleTests.swift */; };
3637
SBFT002SBFT002SBFT002002 /* SidebarFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = SBFT001SBFT001SBFT001001 /* SidebarFilterTests.swift */; };
3738
NPT002NPT002NPT002002 /* NetworkPeersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = NPT001NPT001NPT001001 /* NetworkPeersTests.swift */; };
3839
PFPT002PFPT002PFPT002002 /* PacketFilterPresetsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = PFPT001PFPT001PFPT001001 /* PacketFilterPresetsTests.swift */; };
@@ -73,6 +74,7 @@
7374
THBT002THBT002THBT002002 /* TacticalHoverButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = THBT001THBT001THBT001001 /* TacticalHoverButton.swift */; };
7475
TTRV002TTRV002TTRV002002 /* TacticalTableRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = TTRV001TTRV001TTRV001001 /* TacticalTableRowView.swift */; };
7576
TESV002TESV002TESV002002 /* TacticalEmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = TESV001TESV001TESV001001 /* TacticalEmptyStateView.swift */; };
77+
VMCV002VMCV002VMCV002002 /* VMCardCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = VMCV001VMCV001VMCV001001 /* VMCardCellView.swift */; };
7678
ICMW002ICMW002ICMW002002 /* ISOCacheManagerWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = ICMW001ICMW001ICMW001001 /* ISOCacheManagerWindow.swift */; };
7779
SSWC002SSWC002SSWC002002 /* SwitchStatisticsWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = SSWC001SSWC001SSWC001001 /* SwitchStatisticsWindowController.swift */; };
7880
TSSC002TSSC002TSSC002002 /* TacticalSidebarSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = TSSC001TSSC001TSSC001001 /* TacticalSidebarSection.swift */; };
@@ -129,6 +131,7 @@
129131
DD1111DD11111111DD111DD1 /* VMConfigurationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMConfigurationTests.swift; sourceTree = "<group>"; };
130132
SPKT001SPKT001SPKT001001 /* SparklineViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SparklineViewTests.swift; sourceTree = "<group>"; };
131133
TUIT001TUIT001TUIT001001 /* TacticalUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TacticalUITests.swift; sourceTree = "<group>"; };
134+
VCCT001VCCT001VCCT001001 /* VMCardCellAndCycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMCardCellAndCycleTests.swift; sourceTree = "<group>"; };
132135
SBFT001SBFT001SBFT001001 /* SidebarFilterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarFilterTests.swift; sourceTree = "<group>"; };
133136
NPT001NPT001NPT001001 /* NetworkPeersTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkPeersTests.swift; sourceTree = "<group>"; };
134137
PFPT001PFPT001PFPT001001 /* PacketFilterPresetsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketFilterPresetsTests.swift; sourceTree = "<group>"; };
@@ -169,6 +172,7 @@
169172
THBT001THBT001THBT001001 /* TacticalHoverButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TacticalHoverButton.swift; sourceTree = "<group>"; };
170173
TTRV001TTRV001TTRV001001 /* TacticalTableRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TacticalTableRowView.swift; sourceTree = "<group>"; };
171174
TESV001TESV001TESV001001 /* TacticalEmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TacticalEmptyStateView.swift; sourceTree = "<group>"; };
175+
VMCV001VMCV001VMCV001001 /* VMCardCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VMCardCellView.swift; sourceTree = "<group>"; };
172176
ICMW001ICMW001ICMW001001 /* ISOCacheManagerWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ISOCacheManagerWindow.swift; sourceTree = "<group>"; };
173177
SSWC001SSWC001SSWC001001 /* SwitchStatisticsWindowController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwitchStatisticsWindowController.swift; sourceTree = "<group>"; };
174178
TSSC001TSSC001TSSC001001 /* TacticalSidebarSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TacticalSidebarSection.swift; sourceTree = "<group>"; };
@@ -250,6 +254,7 @@
250254
THBT001THBT001THBT001001 /* TacticalHoverButton.swift */,
251255
TTRV001TTRV001TTRV001001 /* TacticalTableRowView.swift */,
252256
TESV001TESV001TESV001001 /* TacticalEmptyStateView.swift */,
257+
VMCV001VMCV001VMCV001001 /* VMCardCellView.swift */,
253258
ICMW001ICMW001ICMW001001 /* ISOCacheManagerWindow.swift */,
254259
SSWC001SSWC001SSWC001001 /* SwitchStatisticsWindowController.swift */,
255260
TSSC001TSSC001TSSC001001 /* TacticalSidebarSection.swift */,
@@ -307,6 +312,7 @@
307312
DD1111DD11111111DD111DD1 /* VMConfigurationTests.swift */,
308313
SPKT001SPKT001SPKT001001 /* SparklineViewTests.swift */,
309314
TUIT001TUIT001TUIT001001 /* TacticalUITests.swift */,
315+
VCCT001VCCT001VCCT001001 /* VMCardCellAndCycleTests.swift */,
310316
SBFT001SBFT001SBFT001001 /* SidebarFilterTests.swift */,
311317
NPT001NPT001NPT001001 /* NetworkPeersTests.swift */,
312318
PFPT001PFPT001PFPT001001 /* PacketFilterPresetsTests.swift */,
@@ -475,6 +481,7 @@
475481
THBT002THBT002THBT002002 /* TacticalHoverButton.swift in Sources */,
476482
TTRV002TTRV002TTRV002002 /* TacticalTableRowView.swift in Sources */,
477483
TESV002TESV002TESV002002 /* TacticalEmptyStateView.swift in Sources */,
484+
VMCV002VMCV002VMCV002002 /* VMCardCellView.swift in Sources */,
478485
ICMW002ICMW002ICMW002002 /* ISOCacheManagerWindow.swift in Sources */,
479486
SSWC002SSWC002SSWC002002 /* SwitchStatisticsWindowController.swift in Sources */,
480487
TSSC002TSSC002TSSC002002 /* TacticalSidebarSection.swift in Sources */,
@@ -511,6 +518,7 @@
511518
CC1111CC11111111CC111CC1 /* VMConfigurationTests.swift in Sources */,
512519
SPKT002SPKT002SPKT002002 /* SparklineViewTests.swift in Sources */,
513520
TUIT002TUIT002TUIT002002 /* TacticalUITests.swift in Sources */,
521+
VCCT002VCCT002VCCT002002 /* VMCardCellAndCycleTests.swift in Sources */,
514522
SBFT002SBFT002SBFT002002 /* SidebarFilterTests.swift in Sources */,
515523
NPT002NPT002NPT002002 /* NetworkPeersTests.swift in Sources */,
516524
PFPT002PFPT002PFPT002002 /* PacketFilterPresetsTests.swift in Sources */,
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
//
2+
// VMCardCellAndCycleTests.swift
3+
// SecVFTests
4+
//
5+
// Tests for the multi-line VM card cell and the network-mode cycle.
6+
// Cycle logic is the priority — the cell view tests just verify the
7+
// card's public configuration surface (full UI rendering is covered
8+
// visually).
9+
//
10+
11+
import XCTest
12+
@testable import SecVF
13+
14+
@MainActor
15+
final class VMCardCellAndCycleTests: XCTestCase {
16+
17+
// MARK: - Network-mode cycle (pure logic)
18+
19+
func testCycleNATGoesToVirtualIsolated() {
20+
let nat = VirtualNetworkConfig(mode: .nat, routerVMId: nil, isRouter: false)
21+
let next = VMManager.nextNetworkConfig(after: nat)
22+
XCTAssertEqual(next.mode, .virtual)
23+
XCTAssertFalse(next.isRouter)
24+
XCTAssertNil(next.routerVMId)
25+
}
26+
27+
func testCycleVirtualIsolatedGoesToRouter() {
28+
let virt = VirtualNetworkConfig(mode: .virtual, routerVMId: nil, isRouter: false)
29+
let next = VMManager.nextNetworkConfig(after: virt)
30+
XCTAssertEqual(next.mode, .virtual)
31+
XCTAssertTrue(next.isRouter)
32+
XCTAssertNil(next.routerVMId)
33+
}
34+
35+
func testCycleRouterGoesToNAT() {
36+
let router = VirtualNetworkConfig(mode: .virtual, routerVMId: nil, isRouter: true)
37+
let next = VMManager.nextNetworkConfig(after: router)
38+
XCTAssertEqual(next.mode, .nat)
39+
XCTAssertFalse(next.isRouter)
40+
XCTAssertNil(next.routerVMId)
41+
}
42+
43+
func testCycleVirtualGuestDropsToNATCleanly() {
44+
// A virtual-mode VM with a routerVMId set (guest of someone)
45+
// shouldn't drag its stale guest-of relationship through the
46+
// cycle. The cycle drops it to NAT and clears the routerVMId.
47+
let guest = VirtualNetworkConfig(mode: .virtual,
48+
routerVMId: UUID(),
49+
isRouter: false)
50+
let next = VMManager.nextNetworkConfig(after: guest)
51+
XCTAssertEqual(next.mode, .nat)
52+
XCTAssertNil(next.routerVMId)
53+
XCTAssertFalse(next.isRouter)
54+
}
55+
56+
func testCycleIsCircular() {
57+
// Four cycles from NAT returns to NAT-equivalent state. Note:
58+
// a guest VM short-circuits to NAT in one step, so this test
59+
// walks the canonical NAT → Virtual → Router → NAT path.
60+
var cfg = VirtualNetworkConfig(mode: .nat, routerVMId: nil, isRouter: false)
61+
let initial = cfg
62+
cfg = VMManager.nextNetworkConfig(after: cfg) // → Virtual
63+
cfg = VMManager.nextNetworkConfig(after: cfg) // → Router
64+
cfg = VMManager.nextNetworkConfig(after: cfg) // → NAT
65+
XCTAssertEqual(cfg.mode, initial.mode)
66+
XCTAssertEqual(cfg.isRouter, initial.isRouter)
67+
XCTAssertEqual(cfg.routerVMId, initial.routerVMId)
68+
}
69+
70+
// MARK: - Network chip rendering
71+
72+
func testNetworkChipShowsNATForNATMode() {
73+
let cfg = VirtualNetworkConfig(mode: .nat, routerVMId: nil, isRouter: false)
74+
let (text, _) = VMCardCellView.networkChipSpec(for: cfg)
75+
XCTAssertTrue(text.contains("NAT"),
76+
"NAT chip must mention NAT in its label, got: \(text)")
77+
}
78+
79+
func testNetworkChipShowsRouterForRouterMode() {
80+
let cfg = VirtualNetworkConfig(mode: .virtual, routerVMId: nil, isRouter: true)
81+
let (text, _) = VMCardCellView.networkChipSpec(for: cfg)
82+
XCTAssertTrue(text.uppercased().contains("ROUTER"),
83+
"Router chip must mention ROUTER, got: \(text)")
84+
}
85+
86+
func testNetworkChipShowsGuestForGuestMode() {
87+
let cfg = VirtualNetworkConfig(mode: .virtual,
88+
routerVMId: UUID(),
89+
isRouter: false)
90+
let (text, _) = VMCardCellView.networkChipSpec(for: cfg)
91+
XCTAssertTrue(text.uppercased().contains("GUEST"),
92+
"Guest chip must mention GUEST, got: \(text)")
93+
}
94+
95+
func testNetworkChipShowsIsolatedForVirtualNoRouter() {
96+
let cfg = VirtualNetworkConfig(mode: .virtual, routerVMId: nil, isRouter: false)
97+
let (text, _) = VMCardCellView.networkChipSpec(for: cfg)
98+
XCTAssertTrue(text.uppercased().contains("ISOLATED"),
99+
"Isolated chip must mention ISOLATED, got: \(text)")
100+
}
101+
102+
// MARK: - Status pill spec
103+
104+
func testStatusPillTextMatchesEachStatus() {
105+
let cases: [(VMStatus, String)] = [
106+
(.running, "RUNNING"),
107+
(.starting, "STARTING"),
108+
(.stopping, "STOPPING"),
109+
(.stopped, "STOPPED"),
110+
]
111+
for (status, expected) in cases {
112+
let (_, text, _) = VMCardCellView.statusPillSpec(for: status)
113+
XCTAssertEqual(text, expected,
114+
"Status \(status) should pill-render as \(expected), got \(text)")
115+
}
116+
}
117+
118+
// MARK: - Formatters
119+
120+
func testFormatPacketCountUsesGroupingSeparator() {
121+
XCTAssertEqual(VMCardCellView.formatPacketCount(0), "0")
122+
XCTAssertEqual(VMCardCellView.formatPacketCount(42), "42")
123+
XCTAssertEqual(VMCardCellView.formatPacketCount(1_234), "1,234")
124+
XCTAssertEqual(VMCardCellView.formatPacketCount(1_234_567),"1,234,567")
125+
}
126+
127+
func testFormatBpsCrossesUnitThresholds() {
128+
XCTAssertTrue(VMCardCellView.formatBps(500).contains("B/s"),
129+
"Sub-kB rates show as raw B/s")
130+
XCTAssertTrue(VMCardCellView.formatBps(2_048).contains("kB/s"),
131+
"2 kB ≥ 1024 should render as kB/s")
132+
XCTAssertTrue(VMCardCellView.formatBps(5_000_000).contains("MB/s"),
133+
"5 MB ≥ 1 MB should render as MB/s")
134+
XCTAssertTrue(VMCardCellView.formatBps(3_000_000_000).contains("GB/s"),
135+
"3 GB ≥ 1 GB should render as GB/s")
136+
}
137+
138+
// MARK: - Cell construction sanity
139+
140+
func testVMCardCellInitialFrameIsRowHeight() {
141+
let cell = VMCardCellView(frame: NSRect(x: 0, y: 0, width: 400,
142+
height: VMCardCellView.rowHeight))
143+
XCTAssertEqual(cell.frame.height, VMCardCellView.rowHeight)
144+
XCTAssertEqual(cell.identifier, VMCardCellView.identifier)
145+
}
146+
147+
func testVMCardCellConfigureSurvivesAllStatuses() {
148+
// Survival-only — fully painting the cell requires a graphics
149+
// context that we don't have in a unit test. Verifying the
150+
// configure() method doesn't crash for any status keeps the
151+
// happy-path bound for table reload integrity.
152+
let cell = VMCardCellView(frame: NSRect(x: 0, y: 0, width: 400,
153+
height: VMCardCellView.rowHeight))
154+
var vm = VMConfiguration(name: "X", bundlePath: "/tmp/X.bundle/")
155+
for status in [VMStatus.running, .starting, .stopping, .stopped] {
156+
vm.status = status
157+
cell.configure(with: vm,
158+
liveDownBps: 100.0,
159+
liveUpBps: 50.0,
160+
trafficSamples: [1, 2, 3],
161+
packetCount: 42)
162+
}
163+
}
164+
}

0 commit comments

Comments
 (0)