You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
82 lines
3.9 KiB
82 lines
3.9 KiB
import Testing
|
|
import Foundation
|
|
import AVFoundation
|
|
import MusicShared
|
|
@testable import Music
|
|
|
|
// End-to-end reproduction of the reported bug:
|
|
// "After streaming a full track, AVPlayer fails with 'Operation Stopped'
|
|
// and the player does NOT auto-advance to the next track."
|
|
//
|
|
// These tests drive the *real* StreamingPlaybackProvider against the *real*
|
|
// StreamingServer, exactly as the app does, and assert that playing a track to
|
|
// its natural end results in a clean finish (onTrackFinished fires, no error).
|
|
@MainActor
|
|
struct StreamingPlaybackEndToEndTests {
|
|
static let testAPIKey = "e2e-test-key"
|
|
|
|
// Spins the main run loop (where AVPlayer delivers its callbacks) until
|
|
// `condition` is true or `timeout` seconds elapse. Returns true if the
|
|
// condition was met. Polls in small slices so an async @MainActor test
|
|
// lets AVPlayer's main-queue observers run.
|
|
private func waitUntil(timeout: Double, _ condition: () -> Bool) async -> Bool {
|
|
let slices = Int(timeout / 0.05)
|
|
for _ in 0..<slices {
|
|
if condition() { return true }
|
|
try? await Task.sleep(nanoseconds: 50_000_000) // 50 ms
|
|
}
|
|
return condition()
|
|
}
|
|
|
|
// Reproduces the auto-advance failure at end-of-track.
|
|
// Steps:
|
|
// 1. Create an in-memory DB and insert a track pointing at a real audio
|
|
// fixture, stored the way the production scanner stores it (absoluteString).
|
|
// 2. Start the real StreamingServer on an OS-assigned port.
|
|
// 3. Create the real StreamingPlaybackProvider pointed at that server.
|
|
// 4. Install an onTrackFinished callback that records the clean-finish signal
|
|
// (this is the callback PlayerViewModel uses to advance to the next track).
|
|
// 5. Begin playback of the track's /file URL and let it play to the end.
|
|
// 6. Wait until EITHER a clean finish fires OR a playback error appears.
|
|
// 7. Assert: no playback error occurred AND the clean-finish callback fired.
|
|
// On the current (buggy) server this fails — playback ends in an error
|
|
// instead of a clean finish, so the next track never plays.
|
|
@Test(.timeLimit(.minutes(1)))
|
|
func playingTrackToEndFiresCleanFinish() async throws {
|
|
// 1. DB + fixture track (stored as absoluteString, like the real scanner).
|
|
let db = try DatabaseService(inMemory: true)
|
|
let fixtureURL = try TestFixtures.shortMP3URL()
|
|
var track = Track.fixture(id: nil, fileURL: fixtureURL.absoluteString, title: "E2E Track")
|
|
try db.insert(&track)
|
|
|
|
// 2. Start the real streaming server.
|
|
let server = StreamingServer(db: db, apiKey: Self.testAPIKey, port: 0)
|
|
try await server.start()
|
|
defer { server.stop() }
|
|
let port = try #require(server.actualPort)
|
|
|
|
// 3. Real provider against the real server.
|
|
let provider = StreamingPlaybackProvider(hostURL: "http://127.0.0.1:\(port)", apiKey: Self.testAPIKey)
|
|
|
|
// 4. Record the clean-finish signal (drives PlayerViewModel.next()).
|
|
var finishedCleanly = false
|
|
provider.onTrackFinished = { finishedCleanly = true }
|
|
|
|
// 5. Start playback of the track.
|
|
let url = try #require(provider.urlForTrack(track))
|
|
provider.play(url: url)
|
|
|
|
// 6. Wait for a terminal outcome: clean finish or error. The fixture is a
|
|
// few seconds long; allow generous headroom for buffering.
|
|
_ = await waitUntil(timeout: 25) {
|
|
finishedCleanly || provider.playbackError != nil
|
|
}
|
|
|
|
// --- Diagnostics (printed regardless of pass/fail) ---
|
|
print("E2E DIAGNOSTIC -> finishedCleanly=\(finishedCleanly), playbackError=\(String(describing: provider.playbackError)), providerDuration=\(provider.duration), currentTime=\(provider.currentTime)")
|
|
|
|
// 7. A fully streamed track must end cleanly so the queue can advance.
|
|
#expect(provider.playbackError == nil)
|
|
#expect(finishedCleanly == true)
|
|
}
|
|
}
|
|
|