Slice 4 of Round 4 — the visible polish. With Slices 1–3 the agent
preamble + non-dismissible banner cover the "agent must know +
user must see" loop, but a closed HUD with no banner visible can
still hide the warning. This slice paints the menu-bar icon red so
the warning is glanceable from anywhere on the screen.
Option A from the slice plan (single-writer-safe):
- `MenuBarIconController.applyHealth(_ health: OverallHealth)` paints
`button.contentTintColor = NSColor.systemRed` for `.loud` and
`.critical`, clears the tint for `.ok` and `.soft`. Template images
recolor automatically with `contentTintColor` so no second icon
asset is needed.
- `currentHealth: OverallHealth?` exposed read-only for tests.
- `AppDelegate.runBootHealth` calls `menuBarController?.applyHealth(
snapshot.overallHealth)` after computing the degradation summary,
so the icon updates on every probe sweep (cold boot + Status panel
re-probe).
Single-writer invariant for HudState (HUD-08) is intact:
`contentTintColor` lives on `NSStatusItemButton`, not in HudState.
`scripts/check-single-writer-hudstate.sh` is green.
1 new test in MenuBarIconControllerTests:
* test_applyHealthTintsRedForLoudAndCritical — exercises all four
OverallHealth cases and asserts contentTintColor toggles correctly.
Boundary gates 18/18 green; App target compiles cleanly.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
105 lines
4.1 KiB
Swift
105 lines
4.1 KiB
Swift
import XCTest
|
|
import AppKit
|
|
import AgentCore
|
|
@testable import Jarvis
|
|
|
|
@MainActor
|
|
final class MenuBarIconControllerTests: XCTestCase {
|
|
func test_stateTransitionUpdatesAccessibilityLabel() {
|
|
let statusBar = NSStatusBar.system
|
|
let item = statusBar.statusItem(withLength: NSStatusItem.variableLength)
|
|
defer { statusBar.removeStatusItem(item) }
|
|
|
|
let menu = MenuBarContextMenu.build(
|
|
setupAction: {},
|
|
devOverlayToggleAction: {},
|
|
stateDumpAction: {}
|
|
)
|
|
let controller = MenuBarIconController(statusItem: item, contextMenu: menu)
|
|
|
|
controller.transition(to: .listening)
|
|
XCTAssertEqual(controller.state, .listening)
|
|
XCTAssertEqual(item.button?.accessibilityLabel(), "Jarvis, listening")
|
|
|
|
controller.transition(to: .thinking)
|
|
XCTAssertEqual(controller.state, .thinking)
|
|
XCTAssertEqual(item.button?.accessibilityLabel(), "Jarvis, thinking")
|
|
}
|
|
|
|
func test_sameStateTransitionIsNoOp() {
|
|
let statusBar = NSStatusBar.system
|
|
let item = statusBar.statusItem(withLength: NSStatusItem.variableLength)
|
|
defer { statusBar.removeStatusItem(item) }
|
|
|
|
let menu = MenuBarContextMenu.build(
|
|
setupAction: {},
|
|
devOverlayToggleAction: {},
|
|
stateDumpAction: {}
|
|
)
|
|
let controller = MenuBarIconController(statusItem: item, contextMenu: menu)
|
|
|
|
controller.transition(to: .idle)
|
|
let firstAnimations = item.button?.layer?.animationKeys()?.sorted() ?? []
|
|
controller.transition(to: .idle) // second call — should no-op
|
|
let secondAnimations = item.button?.layer?.animationKeys()?.sorted() ?? []
|
|
XCTAssertEqual(firstAnimations, secondAnimations)
|
|
}
|
|
|
|
func test_contextMenuItems() {
|
|
let menu = MenuBarContextMenu.build(
|
|
setupAction: {},
|
|
devOverlayToggleAction: {},
|
|
stateDumpAction: {}
|
|
)
|
|
let titles = menu.items.map(\.title)
|
|
XCTAssertTrue(titles.contains("Setup…"))
|
|
XCTAssertTrue(titles.contains("Settings…"))
|
|
XCTAssertTrue(titles.contains("Show Dev Overlay"))
|
|
XCTAssertTrue(titles.contains("Copy State Dump"))
|
|
XCTAssertTrue(titles.contains("Quit Jarvis"))
|
|
// Settings… is disabled per UI-SPEC Surface 4 (P1-scope stub).
|
|
let settings = menu.items.first { $0.title == "Settings…" }
|
|
XCTAssertNotNil(settings)
|
|
XCTAssertFalse(settings!.isEnabled)
|
|
}
|
|
|
|
// MARK: - Round 4 — boot-health tint
|
|
|
|
/// Round 4: `.loud` and `.critical` paint the menu-bar button red;
|
|
/// `.ok` and `.soft` clear the tint.
|
|
func test_applyHealthTintsRedForLoudAndCritical() {
|
|
let statusBar = NSStatusBar.system
|
|
let item = statusBar.statusItem(withLength: NSStatusItem.variableLength)
|
|
defer { statusBar.removeStatusItem(item) }
|
|
let menu = MenuBarContextMenu.build(setupAction: {}, devOverlayToggleAction: {}, stateDumpAction: {})
|
|
let controller = MenuBarIconController(statusItem: item, contextMenu: menu)
|
|
|
|
controller.applyHealth(.critical)
|
|
XCTAssertEqual(controller.currentHealth, .critical)
|
|
XCTAssertEqual(item.button?.contentTintColor, NSColor.systemRed)
|
|
|
|
controller.applyHealth(.loud)
|
|
XCTAssertEqual(controller.currentHealth, .loud)
|
|
XCTAssertEqual(item.button?.contentTintColor, NSColor.systemRed)
|
|
|
|
controller.applyHealth(.soft)
|
|
XCTAssertEqual(controller.currentHealth, .soft)
|
|
XCTAssertNil(item.button?.contentTintColor)
|
|
|
|
controller.applyHealth(.ok)
|
|
XCTAssertEqual(controller.currentHealth, .ok)
|
|
XCTAssertNil(item.button?.contentTintColor)
|
|
}
|
|
|
|
func test_hudStateVoiceOverLabels() {
|
|
XCTAssertEqual(HudState.idle.voiceOverLabel, "Jarvis, idle")
|
|
XCTAssertEqual(HudState.listening.voiceOverLabel, "Jarvis, listening")
|
|
XCTAssertEqual(HudState.thinking.voiceOverLabel, "Jarvis, thinking")
|
|
XCTAssertEqual(HudState.speaking.voiceOverLabel, "Jarvis, speaking")
|
|
XCTAssertEqual(
|
|
HudState.awaitingConfirmation.voiceOverLabel,
|
|
"Jarvis, waiting for your confirmation"
|
|
)
|
|
}
|
|
}
|