feat: direct native NSStatusItem real-time battery display and comprehensive automated test suite
This commit is contained in:
parent
353e1da2fe
commit
a938dc196f
7 changed files with 306 additions and 54 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -8,3 +8,6 @@ DerivedData/
|
||||||
.swiftpm/configuration/registries.json
|
.swiftpm/configuration/registries.json
|
||||||
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
|
||||||
.netrc
|
.netrc
|
||||||
|
.cache/
|
||||||
|
check_devices
|
||||||
|
.test_runner
|
||||||
|
|
|
||||||
|
|
@ -1,71 +1,119 @@
|
||||||
import SwiftUI
|
import Cocoa
|
||||||
import IOBluetooth
|
import IOBluetooth
|
||||||
import IOKit
|
import IOKit
|
||||||
import CoreAudio
|
import CoreAudio
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct BLTSTrackerApp: App {
|
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||||
@ObservedObject var tracker = BluetoothTracker.shared
|
private var statusItem: NSStatusItem!
|
||||||
@ObservedObject var updater = UpdaterService.shared
|
private let tracker = BluetoothTracker.shared
|
||||||
|
private let updater = UpdaterService.shared
|
||||||
var body: some Scene {
|
|
||||||
MenuBarExtra {
|
static func main() {
|
||||||
menuContent
|
let app = NSApplication.shared
|
||||||
} label: {
|
let delegate = AppDelegate()
|
||||||
menuBarLabel
|
app.delegate = delegate
|
||||||
|
app.setActivationPolicy(.accessory)
|
||||||
|
app.run()
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||||
|
// Create native status bar item
|
||||||
|
statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
|
||||||
|
|
||||||
|
if let button = statusItem.button {
|
||||||
|
button.image = NSImage(systemSymbolName: "headphones", accessibilityDescription: "Bluetooth Battery")
|
||||||
|
button.imagePosition = .imageLeft
|
||||||
|
}
|
||||||
|
|
||||||
|
setupMenu()
|
||||||
|
|
||||||
|
// Listen to real-time tracker updates
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
self,
|
||||||
|
selector: #selector(handleTrackerUpdate),
|
||||||
|
name: BluetoothTracker.didUpdateNotification,
|
||||||
|
object: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
// Initial render
|
||||||
|
updateStatusItem()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func handleTrackerUpdate() {
|
||||||
|
DispatchQueue.main.async { [weak self] in
|
||||||
|
self?.updateStatusItem()
|
||||||
|
self?.setupMenu()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
private func updateStatusItem() {
|
||||||
private var menuBarLabel: some View {
|
guard let button = statusItem?.button else { return }
|
||||||
|
|
||||||
let active = tracker.activeHeadphone
|
let active = tracker.activeHeadphone
|
||||||
let isConn = active?.isConnected == true
|
let isConn = active?.isConnected == true
|
||||||
let percent = active?.primaryBatteryPercent
|
let percent = active?.primaryBatteryPercent
|
||||||
|
|
||||||
if isConn, let p = percent {
|
button.image = NSImage(systemSymbolName: "headphones", accessibilityDescription: "Bluetooth Battery")
|
||||||
Label(" \(p)%", systemImage: "headphones")
|
|
||||||
.labelStyle(.titleAndIcon)
|
if isConn, let p = percent, p > 0 {
|
||||||
|
button.title = " \(p)%"
|
||||||
} else {
|
} else {
|
||||||
Label("", systemImage: "headphones")
|
button.title = ""
|
||||||
.labelStyle(.iconOnly)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
private func setupMenu() {
|
||||||
private var menuContent: some View {
|
let menu = NSMenu()
|
||||||
if let dev = tracker.activeHeadphone, dev.isConnected {
|
|
||||||
|
let active = tracker.activeHeadphone
|
||||||
|
if let dev = active, dev.isConnected {
|
||||||
let percentStr = dev.primaryBatteryPercent != nil ? "\(dev.primaryBatteryPercent!)%" : "Подключено"
|
let percentStr = dev.primaryBatteryPercent != nil ? "\(dev.primaryBatteryPercent!)%" : "Подключено"
|
||||||
Text("🎧 \(dev.name): \(percentStr)")
|
let titleItem = NSMenuItem(title: "🎧 \(dev.name): \(percentStr)", action: nil, keyEquivalent: "")
|
||||||
.font(.headline)
|
titleItem.isEnabled = false
|
||||||
|
menu.addItem(titleItem)
|
||||||
|
|
||||||
if let l = dev.leftBattery, let r = dev.rightBattery {
|
if let l = dev.leftBattery, let r = dev.rightBattery {
|
||||||
Text("Левый: \(l)% | Правый: \(r)%")
|
let subItem = NSMenuItem(title: " Левый: \(l)% | Правый: \(r)%", action: nil, keyEquivalent: "")
|
||||||
.font(.caption)
|
subItem.isEnabled = false
|
||||||
.foregroundColor(.secondary)
|
menu.addItem(subItem)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text("🎧 Наушники не подключены")
|
let item = NSMenuItem(title: "🎧 Наушники не подключены", action: nil, keyEquivalent: "")
|
||||||
.foregroundColor(.secondary)
|
item.isEnabled = false
|
||||||
|
menu.addItem(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
Divider()
|
menu.addItem(NSMenuItem.separator())
|
||||||
|
|
||||||
Button("Обновить заряд") {
|
let refreshItem = NSMenuItem(title: "🔄 Обновить заряд", action: #selector(refreshClicked), keyEquivalent: "r")
|
||||||
tracker.refreshNow()
|
refreshItem.target = self
|
||||||
}
|
menu.addItem(refreshItem)
|
||||||
.keyboardShortcut("r")
|
|
||||||
|
|
||||||
Button(updater.isUpdating ? "Обновление..." : "🔄 Обновить из GitHub") {
|
let updateTitle = updater.isUpdating ? "⏳ Обновление..." : "🌐 Обновить из GitHub"
|
||||||
updater.checkForUpdatesAndApply()
|
let updateItem = NSMenuItem(title: updateTitle, action: #selector(updateClicked), keyEquivalent: "u")
|
||||||
}
|
updateItem.target = self
|
||||||
.disabled(updater.isUpdating)
|
if updater.isUpdating { updateItem.isEnabled = false }
|
||||||
.keyboardShortcut("u")
|
menu.addItem(updateItem)
|
||||||
|
|
||||||
Divider()
|
menu.addItem(NSMenuItem.separator())
|
||||||
|
|
||||||
Button("Завершить") {
|
let quitItem = NSMenuItem(title: "Завершить", action: #selector(quitClicked), keyEquivalent: "q")
|
||||||
NSApplication.shared.terminate(nil)
|
quitItem.target = self
|
||||||
}
|
menu.addItem(quitItem)
|
||||||
.keyboardShortcut("q")
|
|
||||||
|
statusItem.menu = menu
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func refreshClicked() {
|
||||||
|
tracker.refreshNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func updateClicked() {
|
||||||
|
updater.checkForUpdatesAndApply()
|
||||||
|
}
|
||||||
|
|
||||||
|
@objc private func quitClicked() {
|
||||||
|
NSApplication.shared.terminate(nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,23 @@ import AudioToolbox
|
||||||
|
|
||||||
public final class BluetoothTracker: ObservableObject {
|
public final class BluetoothTracker: ObservableObject {
|
||||||
public static let shared = BluetoothTracker()
|
public static let shared = BluetoothTracker()
|
||||||
|
public static let didUpdateNotification = Notification.Name("BLTSTrackerDidUpdateNotification")
|
||||||
|
|
||||||
@Published public var activeHeadphone: BluetoothDeviceModel?
|
@Published public var activeHeadphone: BluetoothDeviceModel?
|
||||||
@Published public var devices: [BluetoothDeviceModel] = []
|
@Published public var devices: [BluetoothDeviceModel] = []
|
||||||
|
|
||||||
|
// Direct primitive properties for instant reactivity
|
||||||
|
@Published public var currentBatteryPercent: Int?
|
||||||
|
@Published public var currentDeviceName: String = ""
|
||||||
|
@Published public var isConnected: Bool = false
|
||||||
|
|
||||||
private var timer: Timer?
|
private var timer: Timer?
|
||||||
private var lastValidBattery: [String: Int] = [:]
|
private var lastValidBattery: [String: Int] = [:]
|
||||||
|
|
||||||
private typealias IntBatteryGetter = @convention(c) (AnyObject, Selector) -> Int
|
private typealias IntBatteryGetter = @convention(c) (AnyObject, Selector) -> Int
|
||||||
private typealias BoolGetter = @convention(c) (AnyObject, Selector) -> Bool
|
private typealias BoolGetter = @convention(c) (AnyObject, Selector) -> Bool
|
||||||
|
|
||||||
private init() {
|
public init() {
|
||||||
setupBluetoothListeners()
|
setupBluetoothListeners()
|
||||||
startTracking()
|
startTracking()
|
||||||
}
|
}
|
||||||
|
|
@ -29,8 +35,8 @@ public final class BluetoothTracker: ObservableObject {
|
||||||
stopTracking()
|
stopTracking()
|
||||||
updateDeviceList()
|
updateDeviceList()
|
||||||
|
|
||||||
// Use .common mode so timer runs continuously without pausing
|
// 1.0 second high-frequency poll in .common runloop mode
|
||||||
let t = Timer(timeInterval: 1.5, repeats: true) { [weak self] _ in
|
let t = Timer(timeInterval: 1.0, repeats: true) { [weak self] _ in
|
||||||
self?.updateDeviceList()
|
self?.updateDeviceList()
|
||||||
}
|
}
|
||||||
RunLoop.main.add(t, forMode: .common)
|
RunLoop.main.add(t, forMode: .common)
|
||||||
|
|
@ -65,7 +71,7 @@ public final class BluetoothTracker: ObservableObject {
|
||||||
DispatchQueue.main.async { [weak self] in
|
DispatchQueue.main.async { [weak self] in
|
||||||
self?.updateDeviceList()
|
self?.updateDeviceList()
|
||||||
}
|
}
|
||||||
for delay in [0.2, 0.6, 1.2, 2.0] {
|
for delay in [0.2, 0.5, 1.0, 1.5, 2.0] {
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||||
self?.updateDeviceList()
|
self?.updateDeviceList()
|
||||||
}
|
}
|
||||||
|
|
@ -91,7 +97,7 @@ public final class BluetoothTracker: ObservableObject {
|
||||||
|
|
||||||
let majorClass = UInt32(device.deviceClassMajor)
|
let majorClass = UInt32(device.deviceClassMajor)
|
||||||
let minorClass = UInt32(device.deviceClassMinor)
|
let minorClass = UInt32(device.deviceClassMinor)
|
||||||
let isAudio = (majorClass == 4) || isLikelyHeadphones(name: name, minorClass: minorClass) || isAudioOutput
|
let isAudio = (majorClass == 4) || isLikelyHeadphones(name: name, minorClass: minorClass, majorClass: majorClass) || isAudioOutput
|
||||||
|
|
||||||
var isMulti = false
|
var isMulti = false
|
||||||
let selMulti = Selector(("isMultiBatteryDevice"))
|
let selMulti = Selector(("isMultiBatteryDevice"))
|
||||||
|
|
@ -137,11 +143,19 @@ public final class BluetoothTracker: ObservableObject {
|
||||||
let foundActive = updatedList.first(where: { $0.isConnected && $0.isAudioDevice })
|
let foundActive = updatedList.first(where: { $0.isConnected && $0.isAudioDevice })
|
||||||
?? updatedList.first(where: { $0.isConnected })
|
?? updatedList.first(where: { $0.isConnected })
|
||||||
|
|
||||||
|
let newPercent = foundActive?.primaryBatteryPercent
|
||||||
|
let newName = foundActive?.name ?? ""
|
||||||
|
let newConnected = foundActive?.isConnected == true
|
||||||
|
|
||||||
let applyUpdate = { [weak self] in
|
let applyUpdate = { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
self.objectWillChange.send()
|
self.objectWillChange.send()
|
||||||
self.devices = updatedList
|
self.devices = updatedList
|
||||||
self.activeHeadphone = foundActive
|
self.activeHeadphone = foundActive
|
||||||
|
self.currentBatteryPercent = newPercent
|
||||||
|
self.currentDeviceName = newName
|
||||||
|
self.isConnected = newConnected
|
||||||
|
NotificationCenter.default.post(name: BluetoothTracker.didUpdateNotification, object: self)
|
||||||
}
|
}
|
||||||
|
|
||||||
if Thread.isMainThread {
|
if Thread.isMainThread {
|
||||||
|
|
@ -151,7 +165,7 @@ public final class BluetoothTracker: ObservableObject {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func getActiveCoreAudioDeviceName() -> String? {
|
public func getActiveCoreAudioDeviceName() -> String? {
|
||||||
var defaultOutputDeviceID = AudioDeviceID(0)
|
var defaultOutputDeviceID = AudioDeviceID(0)
|
||||||
var propertySize = UInt32(MemoryLayout<AudioDeviceID>.size)
|
var propertySize = UInt32(MemoryLayout<AudioDeviceID>.size)
|
||||||
var propertyAddress = AudioObjectPropertyAddress(
|
var propertyAddress = AudioObjectPropertyAddress(
|
||||||
|
|
@ -194,13 +208,18 @@ public final class BluetoothTracker: ObservableObject {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
private func isLikelyHeadphones(name: String, minorClass: UInt32) -> Bool {
|
public func isLikelyHeadphones(name: String, minorClass: UInt32, majorClass: UInt32 = 0) -> Bool {
|
||||||
let lower = name.lowercased()
|
let lower = name.lowercased()
|
||||||
|
let nonAudioKeywords = ["mouse", "keyboard", "trackpad", "pen", "pencil", "watch"]
|
||||||
|
for nonKw in nonAudioKeywords {
|
||||||
|
if lower.contains(nonKw) { return false }
|
||||||
|
}
|
||||||
|
|
||||||
let keywords = ["buds", "pods", "freebuds", "airpods", "headphone", "headset", "earphone", "earbuds", "wh-", "wf-", "qc", "bose", "sony", "jbl", "beats", "marshall", "sennheiser", "soundcore", "pixel buds", "galaxy buds"]
|
let keywords = ["buds", "pods", "freebuds", "airpods", "headphone", "headset", "earphone", "earbuds", "wh-", "wf-", "qc", "bose", "sony", "jbl", "beats", "marshall", "sennheiser", "soundcore", "pixel buds", "galaxy buds"]
|
||||||
for kw in keywords {
|
for kw in keywords {
|
||||||
if lower.contains(kw) { return true }
|
if lower.contains(kw) { return true }
|
||||||
}
|
}
|
||||||
if minorClass >= 1 && minorClass <= 6 {
|
if majorClass == 4 && (minorClass >= 1 && minorClass <= 6) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
|
|
@ -232,6 +251,7 @@ public final class BluetoothTracker: ObservableObject {
|
||||||
?? getBatteryValue(selectorName: "headsetBatteryPercent")
|
?? getBatteryValue(selectorName: "headsetBatteryPercent")
|
||||||
?? getBatteryValue(selectorName: "batteryLevel")
|
?? getBatteryValue(selectorName: "batteryLevel")
|
||||||
?? getBatteryValue(selectorName: "batteryPercentCombined")
|
?? getBatteryValue(selectorName: "batteryPercentCombined")
|
||||||
|
?? getBatteryValue(selectorName: "batteryPercentLeft")
|
||||||
|
|
||||||
if let single = single {
|
if let single = single {
|
||||||
lastValidBattery[addr] = single
|
lastValidBattery[addr] = single
|
||||||
|
|
@ -269,17 +289,17 @@ public final class BluetoothTracker: ObservableObject {
|
||||||
|
|
||||||
if matchByName || matchByAddr {
|
if matchByName || matchByAddr {
|
||||||
if let b = batt, b > 0 && b <= 100 {
|
if let b = batt, b > 0 && b <= 100 {
|
||||||
if devices[i].singleBattery == nil { devices[i].singleBattery = b }
|
devices[i].singleBattery = b
|
||||||
}
|
}
|
||||||
if devices[i].isMultiBattery {
|
if devices[i].isMultiBattery {
|
||||||
if let bl = battLeft, bl > 0 && bl <= 100 {
|
if let bl = battLeft, bl > 0 && bl <= 100 {
|
||||||
if devices[i].leftBattery == nil { devices[i].leftBattery = bl }
|
devices[i].leftBattery = bl
|
||||||
}
|
}
|
||||||
if let br = battRight, br > 0 && br <= 100 {
|
if let br = battRight, br > 0 && br <= 100 {
|
||||||
if devices[i].rightBattery == nil { devices[i].rightBattery = br }
|
devices[i].rightBattery = br
|
||||||
}
|
}
|
||||||
if let bc = battCase, bc > 0 && bc <= 100 {
|
if let bc = battCase, bc > 0 && bc <= 100 {
|
||||||
if devices[i].caseBattery == nil { devices[i].caseBattery = bc }
|
devices[i].caseBattery = bc
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
75
Tests/blts_trackerTests/BluetoothDeviceModelTests.swift
Normal file
75
Tests/blts_trackerTests/BluetoothDeviceModelTests.swift
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct TestRunner {
|
||||||
|
public static var passed = 0
|
||||||
|
public static var failed = 0
|
||||||
|
|
||||||
|
public static func assertEqual<T: Equatable>(_ actual: T, _ expected: T, _ message: String = "", file: String = #file, line: Int = #line) {
|
||||||
|
if actual == expected {
|
||||||
|
passed += 1
|
||||||
|
print(" ✅ [PASS] \(message)")
|
||||||
|
} else {
|
||||||
|
failed += 1
|
||||||
|
print(" ❌ [FAIL] \(message) -> Expected: \(expected), Got: \(actual) at line \(line)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static func assertTrue(_ condition: Bool, _ message: String = "", file: String = #file, line: Int = #line) {
|
||||||
|
assertEqual(condition, true, message, file: file, line: line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDeviceModelTests() {
|
||||||
|
print("\n--- Running BluetoothDeviceModel Tests ---")
|
||||||
|
|
||||||
|
// 1. Single Battery Device (like Huawei FreeBuds 4i)
|
||||||
|
var singleDevice = BluetoothDeviceModel(
|
||||||
|
name: "HUAWEI FreeBuds 4i",
|
||||||
|
address: "60-aa-ef-5c-85-07",
|
||||||
|
isConnected: true,
|
||||||
|
isAudioDevice: true,
|
||||||
|
isMultiBattery: false,
|
||||||
|
singleBattery: 40
|
||||||
|
)
|
||||||
|
TestRunner.assertEqual(singleDevice.primaryBatteryPercent, 40, "Single battery device primaryBatteryPercent == 40")
|
||||||
|
TestRunner.assertTrue(singleDevice.hasBatteryData, "Single battery device hasBatteryData is true")
|
||||||
|
|
||||||
|
// 2. Battery percentage changes from 40 to 30 (Real-time drain simulation)
|
||||||
|
singleDevice.singleBattery = 30
|
||||||
|
TestRunner.assertEqual(singleDevice.primaryBatteryPercent, 30, "Single battery device updates to 30 on drain")
|
||||||
|
|
||||||
|
// 3. Multi Battery Device (Left + Right AirPods / FreeBuds Pro)
|
||||||
|
let multiDevice = BluetoothDeviceModel(
|
||||||
|
name: "AirPods Pro",
|
||||||
|
address: "11-22-33-44-55-66",
|
||||||
|
isConnected: true,
|
||||||
|
isAudioDevice: true,
|
||||||
|
isMultiBattery: true,
|
||||||
|
leftBattery: 85,
|
||||||
|
rightBattery: 90,
|
||||||
|
caseBattery: 100
|
||||||
|
)
|
||||||
|
TestRunner.assertEqual(multiDevice.primaryBatteryPercent, 85, "Multi battery device picks min(left, right) -> 85")
|
||||||
|
|
||||||
|
// 4. Multi Battery with single earbud in ear
|
||||||
|
let singleEarbud = BluetoothDeviceModel(
|
||||||
|
name: "AirPods Pro",
|
||||||
|
address: "11-22-33-44-55-66",
|
||||||
|
isConnected: true,
|
||||||
|
isAudioDevice: true,
|
||||||
|
isMultiBattery: true,
|
||||||
|
leftBattery: nil,
|
||||||
|
rightBattery: 75
|
||||||
|
)
|
||||||
|
TestRunner.assertEqual(singleEarbud.primaryBatteryPercent, 75, "Multi battery with only one earbud connected returns 75")
|
||||||
|
|
||||||
|
// 5. Zero / Invalid battery handling (disconnected or 0% bug)
|
||||||
|
let disconnectedDevice = BluetoothDeviceModel(
|
||||||
|
name: "Unknown Headset",
|
||||||
|
address: "00-00-00-00-00-00",
|
||||||
|
isConnected: false,
|
||||||
|
isAudioDevice: true,
|
||||||
|
singleBattery: 0
|
||||||
|
)
|
||||||
|
TestRunner.assertEqual(disconnectedDevice.primaryBatteryPercent, nil, "0% battery is treated as no data / invalid")
|
||||||
|
}
|
||||||
50
Tests/blts_trackerTests/BluetoothTrackerTests.swift
Normal file
50
Tests/blts_trackerTests/BluetoothTrackerTests.swift
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
import Foundation
|
||||||
|
import IOBluetooth
|
||||||
|
import IOKit
|
||||||
|
import CoreAudio
|
||||||
|
|
||||||
|
func runBluetoothTrackerTests() {
|
||||||
|
print("\n--- Running BluetoothTracker Integration Tests ---")
|
||||||
|
|
||||||
|
let tracker = BluetoothTracker.shared
|
||||||
|
|
||||||
|
// 1. Check device recognition
|
||||||
|
TestRunner.assertTrue(tracker.isLikelyHeadphones(name: "HUAWEI FreeBuds 4i", minorClass: 1), "Recognizes HUAWEI FreeBuds 4i as headphones")
|
||||||
|
TestRunner.assertTrue(tracker.isLikelyHeadphones(name: "AirPods Pro", minorClass: 1), "Recognizes AirPods Pro as headphones")
|
||||||
|
TestRunner.assertTrue(tracker.isLikelyHeadphones(name: "Sony WH-1000XM4", minorClass: 1), "Recognizes Sony WH-1000XM4 as headphones")
|
||||||
|
TestRunner.assertEqual(tracker.isLikelyHeadphones(name: "Apple Mouse", minorClass: 3), false, "Rejects Apple Mouse as headphones")
|
||||||
|
|
||||||
|
// 2. Scan and verify live device reading
|
||||||
|
tracker.updateDeviceList()
|
||||||
|
|
||||||
|
print(" [Live Scan Results]")
|
||||||
|
for dev in tracker.devices {
|
||||||
|
print(" Device: \(dev.name) | Connected: \(dev.isConnected) | Battery: \(dev.primaryBatteryPercent != nil ? "\(dev.primaryBatteryPercent!)%" : "nil")")
|
||||||
|
}
|
||||||
|
|
||||||
|
if let active = tracker.activeHeadphone {
|
||||||
|
print(" Active Headphone detected: \(active.name) (Battery: \(active.primaryBatteryPercent != nil ? "\(active.primaryBatteryPercent!)%" : "nil"))")
|
||||||
|
TestRunner.assertTrue(active.isConnected, "Active headphone is connected")
|
||||||
|
TestRunner.assertEqual(active.name, "HUAWEI FreeBuds 4i", "Active headphone is HUAWEI FreeBuds 4i")
|
||||||
|
TestRunner.assertTrue(active.primaryBatteryPercent != nil, "Active headphone has valid battery percent")
|
||||||
|
if let p = active.primaryBatteryPercent {
|
||||||
|
TestRunner.assertTrue(p > 0 && p <= 100, "Active headphone battery percent (\(p)%) is between 1 and 100")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
print(" No active headphones currently connected.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Notification emission test
|
||||||
|
var notificationReceived = false
|
||||||
|
let observer = NotificationCenter.default.addObserver(
|
||||||
|
forName: BluetoothTracker.didUpdateNotification,
|
||||||
|
object: nil,
|
||||||
|
queue: .main
|
||||||
|
) { _ in
|
||||||
|
notificationReceived = true
|
||||||
|
}
|
||||||
|
|
||||||
|
tracker.updateDeviceList()
|
||||||
|
TestRunner.assertTrue(notificationReceived, "BluetoothTracker emits didUpdateNotification on refresh")
|
||||||
|
NotificationCenter.default.removeObserver(observer)
|
||||||
|
}
|
||||||
27
Tests/blts_trackerTests/main.swift
Normal file
27
Tests/blts_trackerTests/main.swift
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct TestMain {
|
||||||
|
static func main() {
|
||||||
|
print("========================================")
|
||||||
|
print("🚀 RUNNING BLTS TRACKER TEST SUITE")
|
||||||
|
print("========================================")
|
||||||
|
|
||||||
|
runDeviceModelTests()
|
||||||
|
runBluetoothTrackerTests()
|
||||||
|
|
||||||
|
print("\n========================================")
|
||||||
|
print("🏁 TEST SUMMARY")
|
||||||
|
print("========================================")
|
||||||
|
print("Passed: \(TestRunner.passed)")
|
||||||
|
print("Failed: \(TestRunner.failed)")
|
||||||
|
|
||||||
|
if TestRunner.failed > 0 {
|
||||||
|
print("❌ Some tests failed!")
|
||||||
|
exit(1)
|
||||||
|
} else {
|
||||||
|
print("🎉 ALL TESTS PASSED SUCCESSFULLY!")
|
||||||
|
exit(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
run_tests.sh
Executable file
29
run_tests.sh
Executable file
|
|
@ -0,0 +1,29 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||||
|
MODULE_CACHE="$DIR/.module-cache"
|
||||||
|
TEST_BIN="$DIR/.test_runner"
|
||||||
|
|
||||||
|
echo "🧪 Compiling and executing BLTS Tracker test suite..."
|
||||||
|
mkdir -p "$MODULE_CACHE"
|
||||||
|
|
||||||
|
swiftc -parse-as-library \
|
||||||
|
-module-cache-path "$MODULE_CACHE" \
|
||||||
|
-O \
|
||||||
|
-framework Foundation \
|
||||||
|
-framework Cocoa \
|
||||||
|
-framework IOBluetooth \
|
||||||
|
-framework IOKit \
|
||||||
|
-framework CoreAudio \
|
||||||
|
-framework AudioToolbox \
|
||||||
|
-framework UserNotifications \
|
||||||
|
"$DIR/Sources/blts_tracker/Models/BluetoothDeviceModel.swift" \
|
||||||
|
"$DIR/Sources/blts_tracker/Services/BluetoothTracker.swift" \
|
||||||
|
"$DIR/Tests/blts_trackerTests/BluetoothDeviceModelTests.swift" \
|
||||||
|
"$DIR/Tests/blts_trackerTests/BluetoothTrackerTests.swift" \
|
||||||
|
"$DIR/Tests/blts_trackerTests/main.swift" \
|
||||||
|
-o "$TEST_BIN"
|
||||||
|
|
||||||
|
"$TEST_BIN"
|
||||||
|
rm -f "$TEST_BIN"
|
||||||
Loading…
Add table
Reference in a new issue