diff --git a/TopManager.xcodeproj/project.pbxproj b/TopManager.xcodeproj/project.pbxproj index f9577e4..25ca6c0 100644 --- a/TopManager.xcodeproj/project.pbxproj +++ b/TopManager.xcodeproj/project.pbxproj @@ -16,6 +16,7 @@ 341C12CED58388D32ED6C5BB /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4F1ACD0492CAB741595DEAA /* ContentView.swift */; }; 35852165B2DA76AB7F073723 /* MenuBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D71CC63FC2FEF52A069372E /* MenuBarView.swift */; }; 4427B8406B25B8FEDF27CDBA /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 334573CEB47D60570A40B55D /* Assets.xcassets */; }; + 49D85FF611927294159C89D1 /* FormattingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 301352EB5B362C732CE1A246 /* FormattingTests.swift */; }; 501C13D57386C179C0B3B14B /* PowerStorageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6038CCDF3BDF0F8BC93C9D4 /* PowerStorageView.swift */; }; 744F52D036D1F3E081923382 /* ProcessInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CF69D4888FDBB3669619A1A /* ProcessInfo.swift */; }; 7A1144E43159D96564C13164 /* MemoryInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD3EDCDEDF4A0CE1921A3B25 /* MemoryInfo.swift */; }; @@ -38,7 +39,18 @@ FA252F9182ADDA26AD1E54DE /* CPUChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94D5A13CC5B9CF1B90CC2214 /* CPUChartView.swift */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + B01CE3A16D70795D608951EE /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 4BAFF5CBBA9A1302C6CCDCD2 /* Project object */; + proxyType = 1; + remoteGlobalIDString = F86DD5615146D759EB0D44EB; + remoteInfo = TopManager; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ + 0F5A69E0EE7FDD44157911D7 /* TopManagerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TopManagerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 110A314A63AE355A9E04990D /* ProcessDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessDetailView.swift; sourceTree = ""; }; 162FC58F6C44EC1DD7022A86 /* DiskMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskMonitor.swift; sourceTree = ""; }; 19ED582C412C63A0A2E2103E /* CPUMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CPUMonitor.swift; sourceTree = ""; }; @@ -46,6 +58,7 @@ 27FDD19B8DBB7D955A5ECB5B /* MemoryMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryMonitor.swift; sourceTree = ""; }; 295232B4C746511757DCF8FE /* ProcessMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessMonitor.swift; sourceTree = ""; }; 3010F605712E09175D9D8878 /* NetworkInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkInfo.swift; sourceTree = ""; }; + 301352EB5B362C732CE1A246 /* FormattingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormattingTests.swift; sourceTree = ""; }; 334573CEB47D60570A40B55D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 33BCE156CB39EDADFFC938F9 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 347E47F825E496F40FD8ED11 /* NetworkMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkMonitor.swift; sourceTree = ""; }; @@ -92,6 +105,7 @@ isa = PBXGroup; children = ( 36BB5A20B1330E54F2DCF357 /* TopManager */, + D078BA97D4F498E5282AC5E0 /* TopManagerTests */, E6438D9AF8EDEFEDCB087B38 /* Products */, ); sourceTree = ""; @@ -183,10 +197,19 @@ path = PowerStorage; sourceTree = ""; }; + D078BA97D4F498E5282AC5E0 /* TopManagerTests */ = { + isa = PBXGroup; + children = ( + 301352EB5B362C732CE1A246 /* FormattingTests.swift */, + ); + path = TopManagerTests; + sourceTree = ""; + }; E6438D9AF8EDEFEDCB087B38 /* Products */ = { isa = PBXGroup; children = ( A3A3D0B1EB522C9975F71DA8 /* TopManager.app */, + 0F5A69E0EE7FDD44157911D7 /* TopManagerTests.xctest */, ); name = Products; sourceTree = ""; @@ -203,6 +226,24 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + D28E798AA7BEAA0638DFD52E /* TopManagerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 9D898AF16ACAC9453397ADCE /* Build configuration list for PBXNativeTarget "TopManagerTests" */; + buildPhases = ( + 692062A41592E988A6547E4C /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + 48A04DA713F139236C91459E /* PBXTargetDependency */, + ); + name = TopManagerTests; + packageProductDependencies = ( + ); + productName = TopManagerTests; + productReference = 0F5A69E0EE7FDD44157911D7 /* TopManagerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; F86DD5615146D759EB0D44EB /* TopManager */ = { isa = PBXNativeTarget; buildConfigurationList = 56829675F6738AEDCFCDEE52 /* Build configuration list for PBXNativeTarget "TopManager" */; @@ -230,6 +271,10 @@ BuildIndependentTargetsInParallel = YES; LastUpgradeCheck = 1500; TargetAttributes = { + D28E798AA7BEAA0638DFD52E = { + DevelopmentTeam = P3663392R2; + ProvisioningStyle = Automatic; + }; F86DD5615146D759EB0D44EB = { DevelopmentTeam = P3663392R2; ProvisioningStyle = Automatic; @@ -251,6 +296,7 @@ projectRoot = ""; targets = ( F86DD5615146D759EB0D44EB /* TopManager */, + D28E798AA7BEAA0638DFD52E /* TopManagerTests */, ); }; /* End PBXProject section */ @@ -268,6 +314,14 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 692062A41592E988A6547E4C /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 49D85FF611927294159C89D1 /* FormattingTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; CF5286324D09B9417D3070C3 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -304,6 +358,14 @@ }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 48A04DA713F139236C91459E /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = F86DD5615146D759EB0D44EB /* TopManager */; + targetProxy = B01CE3A16D70795D608951EE /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 00F8AAE6BB8D4ED66312B6CB /* Release */ = { isa = XCBuildConfiguration; @@ -390,6 +452,24 @@ }; name = Debug; }; + C0DE23B98ECF14373EAC79A6 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.topmanager.TopManagerTests; + SDKROOT = macosx; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TopManager.app/Contents/MacOS/TopManager"; + }; + name = Release; + }; C4D5AD1B7274BBED9D423AC0 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -468,6 +548,24 @@ }; name = Release; }; + EABC414CD1181D6B70269064 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/../Frameworks", + "@loader_path/../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.topmanager.TopManagerTests; + SDKROOT = macosx; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/TopManager.app/Contents/MacOS/TopManager"; + }; + name = Debug; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -480,6 +578,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; + 9D898AF16ACAC9453397ADCE /* Build configuration list for PBXNativeTarget "TopManagerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + EABC414CD1181D6B70269064 /* Debug */, + C0DE23B98ECF14373EAC79A6 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; A70528C1B1F0BAB178B0B048 /* Build configuration list for PBXProject "TopManager" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/TopManager.xcodeproj/xcshareddata/xcschemes/TopManager.xcscheme b/TopManager.xcodeproj/xcshareddata/xcschemes/TopManager.xcscheme new file mode 100644 index 0000000..9a08ffd --- /dev/null +++ b/TopManager.xcodeproj/xcshareddata/xcschemes/TopManager.xcscheme @@ -0,0 +1,118 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/TopManagerTests/FormattingTests.swift b/TopManagerTests/FormattingTests.swift new file mode 100644 index 0000000..aac0ae7 --- /dev/null +++ b/TopManagerTests/FormattingTests.swift @@ -0,0 +1,114 @@ +import XCTest +@testable import TopManager + +/// Baseline tests proving the test harness works and locking in the existing +/// formatting / model-math behavior so later refactors can't silently break it. +final class FormattingTests: XCTestCase { + + // MARK: - Byte formatting + + func testFormatBytesZero() { + // ByteCountFormatter renders 0 as "Zero KB"/"Zero bytes" depending on OS/locale. + let s = formatBytes(UInt64(0)).lowercased() + XCTAssertTrue(s.contains("zero") || s.contains("0"), "Unexpected zero rendering: \(s)") + } + + func testFormatBytesGigabyteScale() { + // 2 GiB in binary style + let twoGiB = UInt64(2) * 1024 * 1024 * 1024 + let s = formatBytes(twoGiB) + XCTAssertTrue(s.contains("GB"), "Expected GB unit, got \(s)") + XCTAssertTrue(s.contains("2"), "Expected value 2, got \(s)") + } + + func testFormatBytesNegativeDoubleClampsToZero() { + // Double overload clamps negatives to 0 rather than trapping on UInt64(negative) + let s = formatBytes(Double(-500)).lowercased() + XCTAssertTrue(s.contains("zero") || s.contains("0"), "Unexpected clamp rendering: \(s)") + } + + func testFormatBytesPerSecondBelowOne() { + XCTAssertEqual(formatBytesPerSecond(0.4), "0 B/s") + } + + func testFormatBytesPerSecondSuffix() { + let s = formatBytesPerSecond(5 * 1024 * 1024) // 5 MiB/s + XCTAssertTrue(s.hasSuffix("/s"), "Expected /s suffix, got \(s)") + XCTAssertTrue(s.contains("MB"), "Expected MB unit, got \(s)") + } + + // MARK: - Percentage + + func testFormatPercentageDefaultDecimals() { + XCTAssertEqual(formatPercentage(12.345), "12.3%") + } + + func testFormatPercentageCustomDecimals() { + XCTAssertEqual(formatPercentage(12.345, decimals: 2), "12.35%") + } + + // MARK: - Uptime + + func testFormatUptimeMinutesOnly() { + XCTAssertEqual(formatUptime(45 * 60), "45m") + } + + func testFormatUptimeHoursAndMinutes() { + XCTAssertEqual(formatUptime(3 * 3600 + 12 * 60), "3h 12m") + } + + func testFormatUptimeDays() { + XCTAssertEqual(formatUptime(2 * 86400 + 5 * 3600 + 9 * 60), "2d 5h 9m") + } + + // MARK: - Model math + + func testMemoryUsagePercentage() { + let mem = MemoryInfo( + totalMemory: 16_000_000_000, + usedMemory: 8_000_000_000, + freeMemory: 8_000_000_000, + activeMemory: 4_000_000_000, + inactiveMemory: 2_000_000_000, + wiredMemory: 2_000_000_000, + compressedMemory: 1_000_000_000, + cachedMemory: 1_000_000_000, + swapUsed: 0, + swapTotal: 0, + memoryPressure: .nominal + ) + XCTAssertEqual(mem.usagePercentage, 50.0, accuracy: 0.001) + XCTAssertEqual(mem.appMemory, 6_000_000_000) + } + + func testMemoryUsagePercentageZeroTotalIsSafe() { + let mem = MemoryInfo( + totalMemory: 0, usedMemory: 0, freeMemory: 0, activeMemory: 0, + inactiveMemory: 0, wiredMemory: 0, compressedMemory: 0, cachedMemory: 0, + swapUsed: 0, swapTotal: 0, memoryPressure: .unknown + ) + XCTAssertEqual(mem.usagePercentage, 0) + } + + func testVolumeUsagePercentageAndUsedSpace() { + let vol = VolumeInfo( + name: "Macintosh HD", + mountPoint: "/", + totalSpace: 1000, + freeSpace: 250, + fileSystem: "APFS", + isRemovable: false, + isInternal: true + ) + XCTAssertEqual(vol.usedSpace, 750) + XCTAssertEqual(vol.usagePercentage, 75.0, accuracy: 0.001) + } + + func testVolumeFreeGreaterThanTotalClampsUsedToZero() { + let vol = VolumeInfo( + name: "weird", mountPoint: "/x", totalSpace: 100, freeSpace: 200, + fileSystem: "APFS", isRemovable: false, isInternal: true + ) + XCTAssertEqual(vol.usedSpace, 0) + } +} diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..98f6de2 --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,67 @@ +# TopManager — Testing Process + +This document defines the repeatable test process used while evolving TopManager. +Every feature increment must pass all five gates below before it is considered done. + +## Gate 1 — Compile +```bash +scripts/test.sh build +``` +Fast feedback that the code compiles for `platform=macOS,arch=arm64`. First gate on every change. + +## Gate 2 — Unit tests (automated, self-run) +```bash +scripts/test.sh test # or: xcodebuild ... test +``` +Pure-logic is covered by XCTest in `TopManagerTests/` using `@testable import TopManager`. +The suite intentionally targets deterministic logic (no live system state): + +- **Formatting / model math** — bytes, rates, percentages, uptime, usage %. +- **MetricsStore** — ring-buffer trim, persistence round-trip, downsampling per range. +- **Energy heuristic** — score is monotonic in CPU and wakeups, clamped to 0–100. +- **Alert engine** — threshold crossings fire/clear correctly, sustained-window logic, + de-duplication (no alert storms), severity ordering. +- **Health score** — 0–100, decreases with pressure/thermal/swap/disk load. +- **Battery math** — time-remaining and capacity-health formatting. + +Tests must stay hermetic: no sleeping on real timers, no asserting on live CPU numbers. + +## Gate 3 — Runtime smoke test (self-run on this machine) +Launch the built app, drive real load, confirm no crash and data populates. +```bash +# build a runnable .app +xcodebuild -project TopManager.xcodeproj -scheme TopManager -configuration Debug \ + -destination 'platform=macOS,arch=arm64' build CODE_SIGNING_ALLOWED=NO +APP=$(find ~/Library/Developer/Xcode/DerivedData/TopManager-*/Build/Products/Debug -name TopManager.app | head -1) +open "$APP" +# generate CPU load in the background, then verify TopManager surfaces it +yes > /dev/null & LOAD=$!; sleep 8; kill $LOAD +``` +Verify: process list shows the load generator near the top, charts move, no crash in +`Console`/stderr, menu bar updates, alerts fire when thresholds are crossed. + +## Gate 4 — Ground-truth cross-checks +TopManager's numbers are validated against OS tools so we don't ship wrong data: + +| TopManager metric | Ground truth command | +|------------------------|---------------------------------------------| +| Per-process CPU/mem | `ps -Ao pid,pcpu,pmem,comm -r | head` | +| System memory | `vm_stat`, `sysctl vm.swapusage` | +| Disk volumes / free | `df -h` | +| Battery / power | `pmset -g batt`, `ioreg -rn AppleSmartBattery` | +| Thermal state | `pmset -g therm` | +| Network throughput | `netstat -ib` | + +Small deltas are expected (sampling windows differ); order-of-magnitude or ranking +mismatches are bugs. + +## Gate 5 — Regression: original features intact +Manually confirm each shipped feature still works: Processes (sort/search/kill/suspend/ +resume), Apps (activate/hide/quit/force-quit/copy bundle id), Performance (CPU/Mem/Net +charts), Power & Storage (system/GPU/storage/network), and the menu-bar readout. + +## Loop +``` +implement slice → Gate 1 → Gate 2 → Gate 3 → Gate 4 → Gate 5 → checkpoint commit +``` +Repeat per increment until the value rubric in `docs/VALUE_PLAN.md` is met. diff --git a/docs/VALUE_PLAN.md b/docs/VALUE_PLAN.md new file mode 100644 index 0000000..2af1754 --- /dev/null +++ b/docs/VALUE_PLAN.md @@ -0,0 +1,38 @@ +# TopManager — 10× Value Plan + +## Thesis +TopManager today is a polished, **read-only Activity Monitor clone**: it *shows* numbers. +Its value ceiling is "I can see what's using my Mac and kill a process." + +To make it ~10× more valuable we move it from **viewer → system-intelligence & control +center**: it watches the system for you, remembers its history, explains problems, exposes +data Activity Monitor hides, and lets you act — including always-on from the menu bar. + +All existing features are preserved (several are improved). + +## Value rubric (capability scoring) +| Dimension | Before | After (target) | +|---|---|---| +| Per-process depth | CPU, mem, threads, state | + disk I/O rate, energy impact, args, open files, full path, tree | +| Time memory | 60 in-RAM points, lost on quit | persistent history, minutes → 24h, survives restart | +| Proactivity | none | threshold alerts + native notifications + health score + diagnosis | +| Energy / battery | **absent** | battery health (cycles/condition/capacity) + per-process energy | +| Always-on surface | bare "12%" | rich menu-bar HUD: CPU/RAM/net + top consumers + quick-kill | +| Customization | none (all hardcoded) | Settings: refresh rate, menu metric, thresholds, launch-at-login | + +## Increments +0. **Test harness** — XCTest target, `@testable import`, 5-gate process. ✅ +1. **Persistent history** — `MetricsStore` ring buffer on disk; 1m/5m/1h/24h charts. +2. **Deep per-process data** — disk I/O, energy impact, args, open files, path; surface the + (currently unused) `ProcessDetailView` as a real inspector; new sortable columns. +3. **Battery + energy** — IOKit `PowerMonitor`; Energy/Battery UI; menu-bar battery. +4. **Alerts + health** — `AlertEngine`, `UserNotifications`, in-app inbox, 0–100 health score, + "why is my Mac slow?" diagnosis. +5. **Settings + rich menu bar** — configurable refresh/metric/thresholds, launch-at-login + (`SMAppService`), menu-bar HUD with top consumers + quick actions. +6. **Verification** — full test run, runtime smoke, ground-truth cross-checks, sign, commit. + +## Non-negotiables +- Never break a shipped feature (regression Gate 5). +- No new entitlements / no root / no privileged helper — must run & sign as-is. +- Every increment passes all five gates in `docs/TESTING.md` before moving on. diff --git a/project.yml b/project.yml index 3efddb7..ebfa48a 100644 --- a/project.yml +++ b/project.yml @@ -33,3 +33,34 @@ targets: ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon info: path: TopManager/Info.plist + + TopManagerTests: + type: bundle.unit-test + platform: macOS + sources: + - path: TopManagerTests + excludes: + - "**/.DS_Store" + dependencies: + - target: TopManager + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.topmanager.TopManagerTests + GENERATE_INFOPLIST_FILE: YES + TEST_HOST: "$(BUILT_PRODUCTS_DIR)/TopManager.app/Contents/MacOS/TopManager" + BUNDLE_LOADER: "$(TEST_HOST)" + CODE_SIGN_STYLE: Automatic + +schemes: + TopManager: + build: + targets: + TopManager: all + TopManagerTests: [test] + run: + config: Debug + test: + config: Debug + targets: + - TopManagerTests + gatherCoverageData: false diff --git a/scripts/test.sh b/scripts/test.sh new file mode 100755 index 0000000..e3a2b31 --- /dev/null +++ b/scripts/test.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +# Fast build+test loop driver for TopManager. +# Usage: +# scripts/test.sh build # compile only (fast gate) +# scripts/test.sh test # compile + run unit tests +# scripts/test.sh # same as "test" +set -euo pipefail +cd "$(dirname "$0")/.." + +ACTION="${1:-test}" +DEST='platform=macOS,arch=arm64' +COMMON=(-project TopManager.xcodeproj -scheme TopManager -configuration Debug -destination "$DEST" CODE_SIGNING_ALLOWED=NO) + +case "$ACTION" in + build) + xcodebuild "${COMMON[@]}" build 2>&1 | tail -20 + ;; + test) + xcodebuild "${COMMON[@]}" test 2>&1 \ + | grep -E "Test Suite|Executed|error:|warning:.*unused|BUILD|TEST (SUCCEEDED|FAILED)|failed \(" \ + | tail -40 + ;; + *) + echo "unknown action: $ACTION" >&2; exit 2 ;; +esac