Skip to content

Commit b9916ae

Browse files
committed
fix: retain NotificationService in SwiftUI view body
NotificationService was stored as @State in KasetApp but never referenced in the view body. After the WindowGroup → Window change in v0.7.0 (#97), SwiftUI stopped retaining the unused @State, causing the observation task to be cancelled on dealloc. Fix: reference notificationService in the body so SwiftUI tracks it. Add NotificationServiceTests with 8 tests for observation lifecycle, track change detection, and deduplication.
1 parent 08633fe commit b9916ae

File tree

3 files changed

+123
-1
lines changed

3 files changed

+123
-1
lines changed

App/KasetApp.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,9 @@ struct KasetApp: App {
9898

9999
var body: some Scene {
100100
Window("Kaset", id: "main") {
101+
// Keep NotificationService alive by referencing it in the view body
102+
let _ = self.notificationService
103+
101104
// Skip UI during unit tests to prevent window spam
102105
if UITestConfig.isRunningUnitTests, !UITestConfig.isUITestMode {
103106
Color.clear

Core/Services/Notification/NotificationService.swift

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,9 @@ final class NotificationService {
1212
/// nonisolated(unsafe) required for deinit access; Swift 6.2 warning is expected.
1313
nonisolated(unsafe) private var observationTask: Task<Void, Never>?
1414
// swiftformat:enable modifierOrder
15-
private var lastNotifiedTrackId: String?
15+
/// Tracks the last notified track to prevent duplicate notifications.
16+
/// Internal for testing.
17+
private(set) var lastNotifiedTrackId: String?
1618

1719
init(playerService: PlayerService, settingsManager: SettingsManager = .shared) {
1820
self.playerService = playerService
@@ -93,6 +95,14 @@ final class NotificationService {
9395
}
9496
}
9597

98+
/// Whether the observation loop is actively running.
99+
var isObserving: Bool {
100+
if let task = self.observationTask {
101+
return !task.isCancelled
102+
}
103+
return false
104+
}
105+
96106
func stopObserving() {
97107
self.observationTask?.cancel()
98108
self.observationTask = nil
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import Foundation
2+
import Testing
3+
@testable import Kaset
4+
5+
/// Tests for NotificationService track-change observation.
6+
@Suite("NotificationService", .serialized, .tags(.service))
7+
@MainActor
8+
struct NotificationServiceTests {
9+
var playerService: PlayerService
10+
var notificationService: NotificationService
11+
12+
init() {
13+
self.playerService = PlayerService()
14+
self.notificationService = NotificationService(playerService: self.playerService)
15+
}
16+
17+
// MARK: - Observation Lifecycle
18+
19+
@Test("observation task is active after init")
20+
func observationActiveAfterInit() {
21+
#expect(self.notificationService.isObserving)
22+
}
23+
24+
@Test("stopObserving cancels observation task")
25+
func stopObservingCancelsTask() {
26+
self.notificationService.stopObserving()
27+
#expect(!self.notificationService.isObserving)
28+
}
29+
30+
// MARK: - Track Change Detection
31+
32+
@Test("detects track change and updates lastNotifiedTrackId")
33+
func detectsTrackChange() async {
34+
self.playerService.currentTrack = TestFixtures.makeSong(id: "song-1", title: "First Song")
35+
36+
// Wait for polling cycle (500ms) + margin
37+
try? await Task.sleep(for: .milliseconds(700))
38+
39+
#expect(self.notificationService.lastNotifiedTrackId == "song-1")
40+
}
41+
42+
@Test("detects multiple track changes")
43+
func detectsMultipleTrackChanges() async {
44+
self.playerService.currentTrack = TestFixtures.makeSong(id: "song-1", title: "First Song")
45+
try? await Task.sleep(for: .milliseconds(700))
46+
#expect(self.notificationService.lastNotifiedTrackId == "song-1")
47+
48+
self.playerService.currentTrack = TestFixtures.makeSong(id: "song-2", title: "Second Song")
49+
try? await Task.sleep(for: .milliseconds(700))
50+
#expect(self.notificationService.lastNotifiedTrackId == "song-2")
51+
}
52+
53+
@Test("does not notify for same track twice")
54+
func doesNotNotifyForSameTrackTwice() async {
55+
self.playerService.currentTrack = TestFixtures.makeSong(id: "song-1", title: "First Song")
56+
try? await Task.sleep(for: .milliseconds(700))
57+
#expect(self.notificationService.lastNotifiedTrackId == "song-1")
58+
59+
// Set a different track, then back to the same one
60+
self.playerService.currentTrack = TestFixtures.makeSong(id: "song-2", title: "Second Song")
61+
try? await Task.sleep(for: .milliseconds(700))
62+
63+
// The lastNotifiedTrackId should now be song-2, meaning song-1 wasn't skipped
64+
#expect(self.notificationService.lastNotifiedTrackId == "song-2")
65+
}
66+
67+
@Test("skips tracks with Loading... title")
68+
func skipsLoadingTracks() async {
69+
self.playerService.currentTrack = TestFixtures.makeSong(id: "loading-track", title: "Loading...")
70+
try? await Task.sleep(for: .milliseconds(700))
71+
72+
#expect(self.notificationService.lastNotifiedTrackId == nil)
73+
}
74+
75+
@Test("notifies after Loading... resolves to real title")
76+
func notifiesAfterLoadingResolves() async {
77+
// First set loading placeholder
78+
self.playerService.currentTrack = TestFixtures.makeSong(id: "song-1", title: "Loading...")
79+
try? await Task.sleep(for: .milliseconds(700))
80+
#expect(self.notificationService.lastNotifiedTrackId == nil)
81+
82+
// Now resolve to real track (same id, different title)
83+
self.playerService.currentTrack = TestFixtures.makeSong(id: "song-1", title: "Real Song")
84+
try? await Task.sleep(for: .milliseconds(700))
85+
#expect(self.notificationService.lastNotifiedTrackId == "song-1")
86+
}
87+
88+
@Test("no notification when track is nil")
89+
func noNotificationWhenTrackIsNil() async {
90+
self.playerService.currentTrack = nil
91+
try? await Task.sleep(for: .milliseconds(700))
92+
93+
#expect(self.notificationService.lastNotifiedTrackId == nil)
94+
}
95+
96+
// MARK: - Service Retention
97+
98+
@Test("service remains active after multiple polling cycles")
99+
func serviceRemainsActiveAfterPolling() async {
100+
// Verify the observation task survives multiple cycles
101+
try? await Task.sleep(for: .seconds(2))
102+
#expect(self.notificationService.isObserving)
103+
104+
// And still detects changes
105+
self.playerService.currentTrack = TestFixtures.makeSong(id: "late-song", title: "Late Song")
106+
try? await Task.sleep(for: .milliseconds(700))
107+
#expect(self.notificationService.lastNotifiedTrackId == "late-song")
108+
}
109+
}

0 commit comments

Comments
 (0)