From 089e24d3997a965a86273a81cc8b4964651288e8 Mon Sep 17 00:00:00 2001 From: Sigve Hansen Date: Fri, 11 Sep 2026 12:45:54 +0200 Subject: [PATCH] feat: more tests for the native implementations --- .../bcc/bccm_player/DownloadMappingTest.kt | 155 ++++++++++ .../bcc/bccm_player/utils/TrackUtilsTest.kt | 269 ++++++++++++++++++ example/ios/Runner.xcodeproj/project.pbxproj | 12 + .../RunnerTests/DownloaderStateTests.swift | 187 ++++++++++++ .../RunnerTests/MediaItemMapperTests.swift | 152 ++++++++++ .../ios/RunnerTests/MetadataUtilsTests.swift | 114 ++++++++ example/pubspec.lock | 2 +- 7 files changed, 890 insertions(+), 1 deletion(-) create mode 100644 android/src/test/kotlin/media/bcc/bccm_player/DownloadMappingTest.kt create mode 100644 android/src/test/kotlin/media/bcc/bccm_player/utils/TrackUtilsTest.kt create mode 100644 example/ios/RunnerTests/DownloaderStateTests.swift create mode 100644 example/ios/RunnerTests/MediaItemMapperTests.swift create mode 100644 example/ios/RunnerTests/MetadataUtilsTests.swift diff --git a/android/src/test/kotlin/media/bcc/bccm_player/DownloadMappingTest.kt b/android/src/test/kotlin/media/bcc/bccm_player/DownloadMappingTest.kt new file mode 100644 index 0000000..9d4aedb --- /dev/null +++ b/android/src/test/kotlin/media/bcc/bccm_player/DownloadMappingTest.kt @@ -0,0 +1,155 @@ +package media.bcc.bccm_player + +import androidx.media3.exoplayer.offline.Download +import androidx.media3.exoplayer.offline.DownloadProgress +import androidx.media3.exoplayer.offline.DownloadRequest +import androidx.media3.common.C +import android.net.Uri +import media.bcc.bccm_player.pigeon.DownloaderApi.DownloadStatus +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Maps media3's [Download] onto the pigeon model the Dart side consumes. + * + * Everything here is a silent-degradation path: the `DownloadInfo` payload is + * decoded inside a `try/catch` that falls back to placeholder values, and the + * status/progress conversions have no error case at all. Nothing crashes when + * one of them is wrong — a download just shows up with the wrong title, the + * wrong state, or impossible progress. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class DownloadMappingTest { + + // MARK: Status + + @Test + fun mapsEveryMedia3State() { + assertEquals(DownloadStatus.DOWNLOADING, toApiDownloadStatus(Download.STATE_DOWNLOADING)) + assertEquals(DownloadStatus.PAUSED, toApiDownloadStatus(Download.STATE_STOPPED)) + assertEquals(DownloadStatus.REMOVING, toApiDownloadStatus(Download.STATE_REMOVING)) + assertEquals(DownloadStatus.FINISHED, toApiDownloadStatus(Download.STATE_COMPLETED)) + assertEquals(DownloadStatus.FAILED, toApiDownloadStatus(Download.STATE_FAILED)) + } + + /** + * Documents current behaviour, which is worth a second look: media3's + * `STATE_QUEUED` falls through to `PAUSED`, even though [DownloadStatus.QUEUED] + * exists and `Downloader.startDownload` reports exactly that for the same + * conceptual state. A download therefore reads QUEUED when created and PAUSED + * once DownloadManager reports back on it. + */ + @Test + fun queuedIsReportedAsPaused() { + assertEquals(DownloadStatus.PAUSED, toApiDownloadStatus(Download.STATE_QUEUED)) + assertEquals(DownloadStatus.PAUSED, toApiDownloadStatus(Download.STATE_RESTARTING)) + } + + @Test + fun unknownStateFallsBackToPaused() { + assertEquals(DownloadStatus.PAUSED, toApiDownloadStatus(9999)) + } + + // MARK: Model + + @Test + fun mapsRequestAndProgress() { + val model = download( + state = Download.STATE_DOWNLOADING, + percentDownloaded = 42f, + data = """{"title":"Episode 1","audioTrackIds":["no"],"videoTrackIds":["720"],"additionalData":{"k":"v"}}""", + ).toDownloaderApiModel() + + assertEquals("download-1", model.key) + assertEquals(CONTENT_URL, model.config.url) + assertEquals("application/x-mpegURL", model.config.mimeType) + assertEquals("Episode 1", model.config.title) + assertEquals(listOf("no"), model.config.audioTrackIds) + assertEquals(listOf("720"), model.config.videoTrackIds) + assertEquals(mapOf("k" to "v"), model.config.additionalData) + assertEquals("downloaded://download-1", model.offlineUrl) + assertEquals(0.42, model.fractionDownloaded, 0.0001) + assertEquals(DownloadStatus.DOWNLOADING, model.status) + assertNull(model.error) + } + + /** + * The `DownloadInfo` payload is our own JSON inside media3's opaque + * `request.data`. A decode failure is swallowed, so a schema change silently + * degrades every existing download to a "-" title with no tracks rather than + * failing loudly. Pinning it means a change to [DownloadInfo] has to be a + * deliberate one. + */ + @Test + fun undecodableDownloadInfoDegradesToPlaceholders() { + val model = download(data = "not json at all").toDownloaderApiModel() + + assertEquals("-", model.config.title) + assertEquals(emptyList(), model.config.audioTrackIds) + assertEquals(emptyList(), model.config.videoTrackIds) + assertEquals(emptyMap(), model.config.additionalData) + // The parts that come from the request itself still survive. + assertEquals("download-1", model.key) + assertEquals(CONTENT_URL, model.config.url) + } + + /** + * media3 reports `C.PERCENTAGE_UNSET` (-1) while no estimate is available — + * `SegmentDownloader` does exactly this for HLS until the content length is + * known. Dividing by 100 turns that into -0.01, so `fractionDownloaded` is + * briefly negative rather than 0. Nothing in the plugin clamps it. + */ + @Test + fun unknownProgressBecomesNegativeFraction() { + val model = download(percentDownloaded = C.PERCENTAGE_UNSET.toFloat()).toDownloaderApiModel() + + assertEquals(-0.01, model.fractionDownloaded, 0.0001) + } + + @Test + fun completedDownloadIsFullyDownloaded() { + val model = download(state = Download.STATE_COMPLETED, percentDownloaded = 100f) + .toDownloaderApiModel() + + assertEquals(1.0, model.fractionDownloaded, 0.0001) + assertEquals(DownloadStatus.FINISHED, model.status) + assertNull(model.error) + } + + /** The real failure reason is dropped; only failed downloads carry any error. */ + @Test + fun onlyFailedDownloadsCarryAnError() { + val failed = download( + state = Download.STATE_FAILED, + failureReason = Download.FAILURE_REASON_UNKNOWN, + ).toDownloaderApiModel() + + assertEquals(DownloadStatus.FAILED, failed.status) + assertEquals("Unknown error", failed.error) + } + + // MARK: Fixtures + + private fun download( + state: Int = Download.STATE_DOWNLOADING, + percentDownloaded: Float = 0f, + data: String = """{"title":"Episode 1","audioTrackIds":[],"videoTrackIds":[],"additionalData":{}}""", + failureReason: Int = Download.FAILURE_REASON_NONE, + ): Download { + val request = DownloadRequest.Builder("download-1", Uri.parse(CONTENT_URL)) + .setMimeType("application/x-mpegURL") + .setData(data.toByteArray()) + .build() + val progress = DownloadProgress().apply { this.percentDownloaded = percentDownloaded } + return Download(request, state, 0L, 0L, C.LENGTH_UNSET.toLong(), 0, failureReason, progress) + } + + companion object { + private const val CONTENT_URL = "https://example.com/stream.m3u8" + } +} diff --git a/android/src/test/kotlin/media/bcc/bccm_player/utils/TrackUtilsTest.kt b/android/src/test/kotlin/media/bcc/bccm_player/utils/TrackUtilsTest.kt new file mode 100644 index 0000000..b679541 --- /dev/null +++ b/android/src/test/kotlin/media/bcc/bccm_player/utils/TrackUtilsTest.kt @@ -0,0 +1,269 @@ +package media.bcc.bccm_player.utils + +import androidx.media3.common.C +import androidx.media3.common.Format +import androidx.media3.common.TrackGroup +import androidx.media3.common.TrackSelectionOverride +import androidx.media3.common.TrackSelectionParameters +import androidx.media3.common.Tracks +import androidx.media3.test.utils.StubPlayer +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Builds the track lists behind the audio/subtitle/quality pickers. Each item + * needs a stable `id`, because that is what Dart sends back to select a track — + * a track whose id changes shape becomes unselectable. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class TrackUtilsTest { + + // MARK: Audio + + @Test + fun mapsAudioTracksAndMarksTheSelectedOne() { + val norwegian = audioFormat(id = "audio-no", language = "no", label = "Norsk") + val english = audioFormat(id = "audio-en", language = "en", label = "English") + val player = playerWith( + group(norwegian, type = C.TRACK_TYPE_AUDIO, selected = true), + group(english, type = C.TRACK_TYPE_AUDIO, selected = false), + ) + + val tracks = TrackUtils.getAudioTracksForPlayer(player) + + assertEquals(2, tracks.size) + assertEquals("audio-no", tracks[0].id) + assertEquals("no", tracks[0].language) + assertEquals("Norsk", tracks[0].label) + assertEquals(128_000L, tracks[0].bitrate) + assertEquals(true, tracks[0].isSelected) + assertEquals(false, tracks[1].isSelected) + } + + /** Not every stream labels its tracks, so the language stands in as the id. */ + @Test + fun fallsBackToLanguageWhenTheFormatHasNoId() { + val player = playerWith( + group(audioFormat(id = null, language = "no"), type = C.TRACK_TYPE_AUDIO), + ) + + val tracks = TrackUtils.getAudioTracksForPlayer(player) + + assertEquals(1, tracks.size) + assertEquals("no", tracks[0].id) + } + + /** With neither an id nor a language there is nothing to select by, so it is dropped. */ + @Test + fun dropsAudioTracksWithNeitherIdNorLanguage() { + val player = playerWith( + group(audioFormat(id = null, language = null), type = C.TRACK_TYPE_AUDIO), + group(audioFormat(id = "audio-en", language = "en"), type = C.TRACK_TYPE_AUDIO), + ) + + val tracks = TrackUtils.getAudioTracksForPlayer(player) + + assertEquals(1, tracks.size) + assertEquals("audio-en", tracks[0].id) + } + + @Test + fun eachListerIgnoresTheOtherTrackTypes() { + val player = playerWith( + group(audioFormat(id = "audio-no", language = "no"), type = C.TRACK_TYPE_AUDIO), + group(textFormat(id = "text-en", language = "en"), type = C.TRACK_TYPE_TEXT), + group(videoFormat(id = "video-720", height = 720), type = C.TRACK_TYPE_VIDEO), + ) + + assertEquals(1, TrackUtils.getAudioTracksForPlayer(player).size) + assertEquals(1, TrackUtils.getTextTracksForPlayer(player).size) + assertEquals(1, TrackUtils.getVideoTracksForPlayer(player).size) + } + + // MARK: Text + + @Test + fun mapsTextTracksAndMarksTheSelectedOne() { + val player = playerWith( + group( + textFormat(id = "text-no", language = "no", label = "Norsk"), + type = C.TRACK_TYPE_TEXT, + selected = true, + ), + group(textFormat(id = "text-en", language = "en", label = "English"), type = C.TRACK_TYPE_TEXT), + ) + + val tracks = TrackUtils.getTextTracksForPlayer(player) + + assertEquals(2, tracks.size) + assertEquals("text-no", tracks[0].id) + assertEquals("Norsk", tracks[0].label) + assertEquals(true, tracks[0].isSelected) + assertEquals(false, tracks[1].isSelected) + } + + @Test + fun dropsTextTracksWithNeitherIdNorLanguage() { + val player = playerWith( + group(textFormat(id = null, language = null), type = C.TRACK_TYPE_TEXT), + ) + + assertTrue(TrackUtils.getTextTracksForPlayer(player).isEmpty()) + } + + // MARK: Video + + /** Quality pickers read top-down, so the list is sorted by descending height. */ + @Test + fun sortsVideoTracksByDescendingHeight() { + val player = playerWith( + group(videoFormat(id = "v-360", height = 360, width = 640), type = C.TRACK_TYPE_VIDEO), + group(videoFormat(id = "v-1080", height = 1080, width = 1920), type = C.TRACK_TYPE_VIDEO), + group(videoFormat(id = "v-720", height = 720, width = 1280), type = C.TRACK_TYPE_VIDEO), + ) + + val heights = TrackUtils.getVideoTracksForPlayer(player).map { it.height } + + assertEquals(listOf(1080L, 720L, 360L), heights) + } + + @Test + fun labelsVideoTracksWithTheirResolution() { + val player = playerWith( + group(videoFormat(id = "v-720", height = 720, width = 1280), type = C.TRACK_TYPE_VIDEO), + ) + + val track = TrackUtils.getVideoTracksForPlayer(player).single() + + assertEquals("1280 x 720", track.label) + assertEquals(1280L, track.width) + assertEquals(720L, track.height) + assertNull(track.language) + } + + /** + * Video selection is reported from the explicit override, not from whatever + * the adaptive selector happens to be playing — otherwise "Auto" would show + * up as a fixed quality. + */ + @Test + fun marksOnlyTheExplicitlyOverriddenVideoTrack() { + val selectedGroup = group( + videoFormat(id = "v-720", height = 720, width = 1280), + type = C.TRACK_TYPE_VIDEO, + selected = true, + ) + val player = playerWith( + tracks = Tracks( + listOf( + selectedGroup, + group(videoFormat(id = "v-1080", height = 1080, width = 1920), type = C.TRACK_TYPE_VIDEO), + ), + ), + parameters = TrackSelectionParameters.DEFAULT.buildUpon() + .addOverride(TrackSelectionOverride(selectedGroup.mediaTrackGroup, 0)) + .build(), + ) + + val tracks = TrackUtils.getVideoTracksForPlayer(player).associateBy { it.id } + + assertEquals(true, tracks["v-720"]!!.isSelected) + assertEquals(false, tracks["v-1080"]!!.isSelected) + } + + @Test + fun noOverrideMeansNoVideoTrackIsSelected() { + val player = playerWith( + group( + videoFormat(id = "v-720", height = 720, width = 1280), + type = C.TRACK_TYPE_VIDEO, + selected = true, + ), + ) + + assertEquals(false, TrackUtils.getVideoTracksForPlayer(player).single().isSelected) + } + + /** An unsupported rendition cannot be played, so offering it would be a dead end. */ + @Test + fun skipsUnsupportedVideoTracks() { + val player = playerWith( + group( + videoFormat(id = "v-4k", height = 2160, width = 3840), + type = C.TRACK_TYPE_VIDEO, + supported = false, + ), + group(videoFormat(id = "v-720", height = 720, width = 1280), type = C.TRACK_TYPE_VIDEO), + ) + + val tracks = TrackUtils.getVideoTracksForPlayer(player) + + assertEquals(1, tracks.size) + assertEquals("v-720", tracks.single().id) + } + + @Test + fun unsetFrameRateIsReportedAsNull() { + val player = playerWith( + group(videoFormat(id = "v-720", height = 720, width = 1280), type = C.TRACK_TYPE_VIDEO), + ) + + assertNull(TrackUtils.getVideoTracksForPlayer(player).single().frameRate) + } + + // MARK: Fixtures + + private fun audioFormat(id: String?, language: String?, label: String? = null) = + Format.Builder() + .setId(id) + .setLanguage(language) + .setLabel(label) + .setAverageBitrate(128_000) + .setSampleMimeType("audio/mp4a-latm") + .build() + + private fun textFormat(id: String?, language: String?, label: String? = null) = + Format.Builder() + .setId(id) + .setLanguage(language) + .setLabel(label) + .setAverageBitrate(1_000) + .setSampleMimeType("text/vtt") + .build() + + private fun videoFormat(id: String?, height: Int, width: Int = 1280) = + Format.Builder() + .setId(id) + .setWidth(width) + .setHeight(height) + .setAverageBitrate(2_000_000) + .setSampleMimeType("video/avc") + .build() + + private fun group( + format: Format, + type: Int, + selected: Boolean = false, + supported: Boolean = true, + ): Tracks.Group = Tracks.Group( + TrackGroup(format.id ?: "group-$type-${format.hashCode()}", format), + false, + intArrayOf(if (supported) C.FORMAT_HANDLED else C.FORMAT_UNSUPPORTED_TYPE), + booleanArrayOf(selected), + ) + + private fun playerWith(vararg groups: Tracks.Group) = + playerWith(Tracks(groups.toList()), TrackSelectionParameters.DEFAULT) + + private fun playerWith(tracks: Tracks, parameters: TrackSelectionParameters) = + object : StubPlayer() { + override fun getCurrentTracks(): Tracks = tracks + override fun getTrackSelectionParameters(): TrackSelectionParameters = parameters + } +} diff --git a/example/ios/Runner.xcodeproj/project.pbxproj b/example/ios/Runner.xcodeproj/project.pbxproj index 15bdb11..58db115 100644 --- a/example/ios/Runner.xcodeproj/project.pbxproj +++ b/example/ios/Runner.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 0C7256387195F52681D91559 /* MetadataUtilsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E3FAA0283D041E2A9CB5E85 /* MetadataUtilsTests.swift */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 1E06713558C0612C8B9B077E /* PigeonCodecTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DFB461A7F0C7E7277A0964C /* PigeonCodecTests.swift */; }; 350C43BD50A57D266CA800DF /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 29B01712CCCD160DC1349D8B /* Foundation.framework */; }; @@ -18,7 +19,9 @@ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; A7A39AC7A6874284500D82D6 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 206A8CEA31EF7CF329E901B1 /* Pods_RunnerTests.framework */; }; + BAE2E2D5CBF862E34E730DDB /* MediaItemMapperTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CA82C398DC2B81C21678EDE /* MediaItemMapperTests.swift */; }; C409C58208A84FF9846C890C /* PigeonConformanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AF6D720B4FCBC9FB8C1EE08 /* PigeonConformanceTests.swift */; }; + CC1E066EAE14780D3A28EFB9 /* DownloaderStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C084D04EC4FBE3356892B14B /* DownloaderStateTests.swift */; }; E0EA73AD39E36C2CF67EE3D2 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 92DC6938909E66F6E88C0E48 /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ @@ -48,10 +51,12 @@ /* Begin PBXFileReference section */ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 1CA82C398DC2B81C21678EDE /* MediaItemMapperTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MediaItemMapperTests.swift; sourceTree = ""; }; 206A8CEA31EF7CF329E901B1 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 29B01712CCCD160DC1349D8B /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS26.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; 355760C778515FD885481D47 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 6E3FAA0283D041E2A9CB5E85 /* MetadataUtilsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MetadataUtilsTests.swift; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 77E7F391D2B1A301538C8431 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -73,6 +78,7 @@ 9BC8535179ABC227B59304C2 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; A90674F5250E2602007380B1 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; AA413CC156740A4B82CFCF92 /* PigeonEnumOrdinalTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PigeonEnumOrdinalTests.swift; sourceTree = ""; }; + C084D04EC4FBE3356892B14B /* DownloaderStateTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DownloaderStateTests.swift; sourceTree = ""; }; D5C7D7CB7CA0A9714AE1A380 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; DE581F079F5E9666A52BBF47 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -105,6 +111,9 @@ 7DFB461A7F0C7E7277A0964C /* PigeonCodecTests.swift */, 7AF6D720B4FCBC9FB8C1EE08 /* PigeonConformanceTests.swift */, AA413CC156740A4B82CFCF92 /* PigeonEnumOrdinalTests.swift */, + C084D04EC4FBE3356892B14B /* DownloaderStateTests.swift */, + 1CA82C398DC2B81C21678EDE /* MediaItemMapperTests.swift */, + 6E3FAA0283D041E2A9CB5E85 /* MetadataUtilsTests.swift */, ); name = RunnerTests; path = RunnerTests; @@ -426,6 +435,9 @@ 1E06713558C0612C8B9B077E /* PigeonCodecTests.swift in Sources */, C409C58208A84FF9846C890C /* PigeonConformanceTests.swift in Sources */, 60B0CB27270D63FD4E935D68 /* PigeonEnumOrdinalTests.swift in Sources */, + CC1E066EAE14780D3A28EFB9 /* DownloaderStateTests.swift in Sources */, + BAE2E2D5CBF862E34E730DDB /* MediaItemMapperTests.swift in Sources */, + 0C7256387195F52681D91559 /* MetadataUtilsTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/example/ios/RunnerTests/DownloaderStateTests.swift b/example/ios/RunnerTests/DownloaderStateTests.swift new file mode 100644 index 0000000..1e14c7b --- /dev/null +++ b/example/ios/RunnerTests/DownloaderStateTests.swift @@ -0,0 +1,187 @@ +import XCTest + +@testable import bccm_player + +/** + The downloader's entire state is a `Codable` blob in `UserDefaults`, and every + read goes through an accessor that swallows decode errors: + + ```swift + var downloaderState: DownloaderState { + get { codable(forKey: Downloader.identifier) ?? DownloaderState(tasks: [:]) } + } + ``` + + `codable(forKey:)` decodes with `try?`. So any incompatible change to this + schema — a renamed property, a changed type, a new non-optional field — does + not fail loudly. It returns nil, falls back to an empty state, and every + download in the user's library silently disappears while the files stay + orphaned on disk. + + `decodesAPinnedPayload` is the guard that matters: it decodes a literal JSON + document captured from the current schema, so a breaking change fails here + instead of in someone's app after an upgrade. + */ +final class DownloaderStateTests: XCTestCase { + + // MARK: Schema + + /// A payload written by the current schema. Changing this to make a failing + /// test pass means accepting that existing downloads will be dropped — add a + /// migration instead, or make the new field optional. + private static let pinnedPayload = """ + { + "tasks": { + "3F2504E0-4F89-11D3-9A0C-0305E82C3301": { + "key": "3F2504E0-4F89-11D3-9A0C-0305E82C3301", + "input": { + "url": "https://example.com/stream.m3u8", + "mimeType": "application/x-mpegURL", + "title": "Episode 1", + "audioTrackIds": ["no", "en"], + "videoTrackIds": ["720"], + "additionalData": { "episodeId": "abc" } + }, + "statusCode": 2, + "progress": 1.0 + } + } + } + """ + + func testDecodesAPinnedPayload() throws { + let state = try JSONDecoder().decode( + DownloaderState.self, + from: Data(Self.pinnedPayload.utf8)) + + let task = try XCTUnwrap(state.tasks["3F2504E0-4F89-11D3-9A0C-0305E82C3301"]) + XCTAssertEqual(task.key.uuidString, "3F2504E0-4F89-11D3-9A0C-0305E82C3301") + XCTAssertEqual(task.input.url.absoluteString, "https://example.com/stream.m3u8") + XCTAssertEqual(task.input.mimeType, "application/x-mpegURL") + XCTAssertEqual(task.input.title, "Episode 1") + XCTAssertEqual(task.input.audioTrackIds, ["no", "en"]) + XCTAssertEqual(task.input.videoTrackIds, ["720"]) + XCTAssertEqual(task.input.additionalData, ["episodeId": "abc"]) + XCTAssertEqual(task.statusCode, DownloadStatus.finished.rawValue) + XCTAssertEqual(task.progress, 1.0, accuracy: 0.0001) + // Absent optionals decode as nil rather than failing the whole document. + XCTAssertNil(task.tempOfflineUrl) + XCTAssertNil(task.bookmark) + XCTAssertNil(task.error) + } + + func testRoundTripsThroughJSON() throws { + let original = DownloaderState(tasks: ["a": Self.sampleTask(progress: 0.25)]) + + let encoded = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(DownloaderState.self, from: encoded) + + let task = try XCTUnwrap(decoded.tasks["a"]) + XCTAssertEqual(task.key, Self.sampleKey) + XCTAssertEqual(task.input.title, "Episode 1") + XCTAssertEqual(task.input.additionalData, ["episodeId": "abc"]) + XCTAssertEqual(task.progress, 0.25, accuracy: 0.0001) + XCTAssertEqual(task.statusCode, DownloadStatus.downloading.rawValue) + } + + /// Demonstrates the fallback the pinned-payload test exists to protect: + /// unreadable state is indistinguishable from no state at all. + func testUnreadablePersistedStateIsSilentlyDiscarded() throws { + let defaults = try XCTUnwrap(UserDefaults(suiteName: #function)) + defer { defaults.removePersistentDomain(forName: #function) } + + defaults.set(Data("not json".utf8), forKey: "state") + let recovered: DownloaderState? = defaults.codable(forKey: "state") + + XCTAssertNil(recovered, "a decode failure yields nil, which the caller turns into an empty library") + } + + func testPersistsThroughUserDefaults() throws { + let defaults = try XCTUnwrap(UserDefaults(suiteName: #function)) + defer { defaults.removePersistentDomain(forName: #function) } + + defaults.set(encodable: DownloaderState(tasks: ["a": Self.sampleTask()]), forKey: "state") + let recovered: DownloaderState? = defaults.codable(forKey: "state") + + XCTAssertEqual(recovered?.tasks["a"]?.input.title, "Episode 1") + } + + // MARK: Task bookkeeping + + func testUpdateTaskInsertsAndReplacesByKey() { + var state = DownloaderState(tasks: [:]) + + _ = state.updateTask(task: Self.sampleTask(progress: 0.1)) + XCTAssertEqual(state.tasks.count, 1) + XCTAssertEqual(state.tasks[Self.sampleKey.uuidString]?.progress, 0.1) + + _ = state.updateTask(task: Self.sampleTask(progress: 0.9)) + XCTAssertEqual(state.tasks.count, 1, "the same key updates in place rather than duplicating") + XCTAssertEqual(state.tasks[Self.sampleKey.uuidString]?.progress, 0.9) + } + + // MARK: Mapping to the pigeon model + + func testMapsToTheDownloadModel() { + let download = Self.sampleTask(progress: 0.5).toDownloadModel() + + XCTAssertEqual(download.key, Self.sampleKey.uuidString) + XCTAssertEqual(download.config.url, "https://example.com/stream.m3u8") + XCTAssertEqual(download.config.mimeType, "application/x-mpegURL") + XCTAssertEqual(download.config.title, "Episode 1") + XCTAssertEqual(download.config.audioTrackIds, ["no", "en"]) + XCTAssertEqual(download.config.videoTrackIds, ["720"]) + XCTAssertEqual(download.fractionDownloaded, 0.5, accuracy: 0.0001) + XCTAssertEqual(download.status, .downloading) + XCTAssertNil(download.error) + // No bookmark resolved yet, so there is nothing to play offline. + XCTAssertNil(download.offlineUrl) + } + + func testEveryStatusCodeSurvivesTheMapping() { + for status in DownloadStatus.allCases { + let download = Self.sampleTask(statusCode: status.rawValue).toDownloadModel() + XCTAssertEqual(download.status, status) + } + } + + /// A status code we no longer recognise (an enum case removed or reordered + /// while state from an older build is still on disk) reads as failed, not as + /// a crash. + func testUnknownStatusCodeBecomesFailed() { + let download = Self.sampleTask(statusCode: 9999).toDownloadModel() + + XCTAssertEqual(download.status, .failed) + } + + func testErrorIsCarriedThrough() { + var task = Self.sampleTask(statusCode: DownloadStatus.failed.rawValue) + task.error = "disk full" + + let download = task.toDownloadModel() + + XCTAssertEqual(download.status, .failed) + XCTAssertEqual(download.error, "disk full") + } + + // MARK: Fixtures + + private static let sampleKey = UUID(uuidString: "3F2504E0-4F89-11D3-9A0C-0305E82C3301")! + + private static func sampleTask( + statusCode: Int = DownloadStatus.downloading.rawValue, + progress: Double = 0.0 + ) -> DownloaderState.TaskState { + DownloaderState.TaskState( + key: sampleKey, + input: DownloaderState.TaskInput( + url: URL(string: "https://example.com/stream.m3u8")!, + mimeType: "application/x-mpegURL", + title: "Episode 1", + audioTrackIds: ["no", "en"], + videoTrackIds: ["720"], + additionalData: ["episodeId": "abc"]), + statusCode: statusCode, + progress: progress) + } +} diff --git a/example/ios/RunnerTests/MediaItemMapperTests.swift b/example/ios/RunnerTests/MediaItemMapperTests.swift new file mode 100644 index 0000000..57efce7 --- /dev/null +++ b/example/ios/RunnerTests/MediaItemMapperTests.swift @@ -0,0 +1,152 @@ +import AVFoundation +import XCTest + +@testable import bccm_player + +/** + Turns the `AVPlayerItem` the native side is holding back into the pigeon + `MediaItem` that Dart sees — the iOS counterpart of Android's + `CastMediaItemConverter`. + + All of the item's identity (its id, mime type, live/offline flags, artwork) + survives only as namespaced metadata strings, so this mapping is where a + mis-typed key turns into a media item that Dart can no longer recognise. + */ +final class MediaItemMapperTests: XCTestCase { + + func testReturnsNilWithoutAPlayerItem() { + XCTAssertNil(MediaItemMapper.mapPlayerItem(nil)) + } + + func testMapsPlayerDataOntoTheMediaItem() throws { + let playerItem = Self.playerItem(playerData: [ + PlayerMetadataConstants.Id: "item-1", + PlayerMetadataConstants.MimeType: "application/x-mpegURL", + PlayerMetadataConstants.IsLive: "false", + PlayerMetadataConstants.IsOffline: "true", + ]) + + let mediaItem = try XCTUnwrap(MediaItemMapper.mapPlayerItem(playerItem)) + + XCTAssertEqual(mediaItem.id, "item-1") + XCTAssertEqual(mediaItem.url, Self.contentUrl) + XCTAssertEqual(mediaItem.mimeType, "application/x-mpegURL") + XCTAssertEqual(mediaItem.isLive, NSNumber(value: false)) + XCTAssertEqual(mediaItem.isOffline, NSNumber(value: true)) + } + + /// Without an id in the metadata one is invented, so callers always get + /// something — but it is not stable across calls, which is worth knowing + /// before relying on it. + func testFallsBackToAGeneratedId() throws { + let first = try XCTUnwrap(MediaItemMapper.mapPlayerItem(Self.playerItem(playerData: [:]))) + let second = try XCTUnwrap(MediaItemMapper.mapPlayerItem(Self.playerItem(playerData: [:]))) + + let firstId = try XCTUnwrap(first.id) + XCTAssertFalse(firstId.isEmpty) + XCTAssertNotNil(UUID(uuidString: firstId)) + XCTAssertNotEqual(firstId, second.id) + } + + /// An asset that has not loaded has an indefinite duration, which is the same + /// signal used for a live stream — so explicit player data has to win. + func testExplicitIsLiveOverridesTheDurationHeuristic() throws { + let unspecified = try XCTUnwrap(MediaItemMapper.mapPlayerItem(Self.playerItem(playerData: [:]))) + XCTAssertEqual(unspecified.isLive, NSNumber(value: true), "indefinite duration reads as live") + + let explicit = try XCTUnwrap(MediaItemMapper.mapPlayerItem( + Self.playerItem(playerData: [PlayerMetadataConstants.IsLive: "false"]))) + XCTAssertEqual(explicit.isLive, NSNumber(value: false)) + } + + /// Anything other than the literal "true" is false — these are strings on the + /// wire, not booleans. + func testFlagsAreComparedAgainstTheLiteralTrue() throws { + let mediaItem = try XCTUnwrap(MediaItemMapper.mapPlayerItem(Self.playerItem(playerData: [ + PlayerMetadataConstants.IsLive: "TRUE", + PlayerMetadataConstants.IsOffline: "yes", + ]))) + + XCTAssertEqual(mediaItem.isLive, NSNumber(value: false)) + XCTAssertEqual(mediaItem.isOffline, NSNumber(value: false)) + } + + func testMapsStandardMetadataAndExtras() throws { + let playerItem = Self.playerItem( + playerData: [PlayerMetadataConstants.ArtworkUri: "https://example.com/art.jpg"], + extras: ["episodeId": "abc", "showId": "def"], + title: "Episode 1", + artist: "The Artist") + + let mediaItem = try XCTUnwrap(MediaItemMapper.mapPlayerItem(playerItem)) + let metadata = try XCTUnwrap(mediaItem.metadata) + + XCTAssertEqual(metadata.title, "Episode 1") + XCTAssertEqual(metadata.artist, "The Artist") + XCTAssertEqual(metadata.artworkUri, "https://example.com/art.jpg") + XCTAssertEqual(metadata.extras?["episodeId"], "abc") + XCTAssertEqual(metadata.extras?["showId"], "def") + // Player data is not extras, even though both travel as metadata. + XCTAssertNil(metadata.extras?[PlayerMetadataConstants.ArtworkUri]) + } + + /// An unloaded asset has a non-finite duration, which must map to nil rather + /// than to NaN crossing the channel. + func testIndefiniteDurationIsReportedAsNil() throws { + let mediaItem = try XCTUnwrap(MediaItemMapper.mapPlayerItem(Self.playerItem(playerData: [:]))) + + XCTAssertNil(mediaItem.metadata?.durationMs) + } + + // MARK: isOffline() + + func testIsOfflineReadsThePlayerDataFlag() { + XCTAssertTrue(Self.playerItem(playerData: [PlayerMetadataConstants.IsOffline: "true"]).isOffline()) + XCTAssertFalse(Self.playerItem(playerData: [PlayerMetadataConstants.IsOffline: "false"]).isOffline()) + XCTAssertFalse(Self.playerItem(playerData: [:]).isOffline()) + } + + // MARK: Fixtures + + private static let contentUrl = "https://example.com/stream.m3u8" + + private static func playerItem( + playerData: [String: String], + extras: [String: String] = [:], + title: String? = nil, + artist: String? = nil + ) -> AVPlayerItem { + let item = AVPlayerItem(url: URL(string: contentUrl)!) + + var metadata: [AVMetadataItem] = [] + for (key, value) in playerData { + if let entry = MetadataUtils.metadataItem( + identifier: key, value: value as NSString, namespace: .BccmPlayer) { + metadata.append(entry) + } + } + for (key, value) in extras { + if let entry = MetadataUtils.metadataItem( + identifier: key, value: value as NSString, namespace: .BccmExtras) { + metadata.append(entry) + } + } + if let title = title { + metadata.append(commonItem(identifier: .commonIdentifierTitle, value: title)) + } + if let artist = artist { + metadata.append(commonItem(identifier: .commonIdentifierArtist, value: artist)) + } + + item.externalMetadata = metadata + return item + } + + private static func commonItem(identifier: AVMetadataIdentifier, value: String) -> AVMetadataItem { + let item = AVMutableMetadataItem() + item.identifier = identifier + item.value = value as NSString + item.extendedLanguageTag = "und" + return item.copy() as! AVMetadataItem + } +} diff --git a/example/ios/RunnerTests/MetadataUtilsTests.swift b/example/ios/RunnerTests/MetadataUtilsTests.swift new file mode 100644 index 0000000..58622ab --- /dev/null +++ b/example/ios/RunnerTests/MetadataUtilsTests.swift @@ -0,0 +1,114 @@ +import AVFoundation +import XCTest + +@testable import bccm_player + +/** + Player data and caller-supplied extras both ride along on `AVPlayerItem` as + QuickTime metadata, separated only by a reverse-DNS namespace prefix + (`media.bcc.player.*` vs `media.bcc.extras.*`). + + Nothing type-checks that separation — it is string prefix matching at both + ends — so a change to how keys are written or parsed shows up as metadata + quietly going missing rather than as a build error. + */ +final class MetadataUtilsTests: XCTestCase { + + func testRoundTripsNamespacedValues() throws { + let items = [ + try XCTUnwrap(MetadataUtils.metadataItem( + identifier: PlayerMetadataConstants.IsLive, + value: "true" as NSString, + namespace: .BccmPlayer)), + try XCTUnwrap(MetadataUtils.metadataItem( + identifier: PlayerMetadataConstants.MimeType, + value: "application/x-mpegURL" as NSString, + namespace: .BccmPlayer)), + ] + + let parsed = MetadataUtils.getNamespacedMetadata(items, namespace: .BccmPlayer) + + XCTAssertEqual(parsed[PlayerMetadataConstants.IsLive], "true") + XCTAssertEqual(parsed[PlayerMetadataConstants.MimeType], "application/x-mpegURL") + } + + /// The two namespaces share one flat metadata array, so each must only ever + /// see its own keys. + func testNamespacesDoNotLeakIntoEachOther() throws { + let items = [ + try XCTUnwrap(MetadataUtils.metadataItem( + identifier: PlayerMetadataConstants.Id, + value: "player-id" as NSString, + namespace: .BccmPlayer)), + try XCTUnwrap(MetadataUtils.metadataItem( + identifier: "episode_id", + value: "extras-id" as NSString, + namespace: .BccmExtras)), + ] + + let playerData = MetadataUtils.getNamespacedMetadata(items, namespace: .BccmPlayer) + let extras = MetadataUtils.getNamespacedMetadata(items, namespace: .BccmExtras) + + XCTAssertEqual(playerData, [PlayerMetadataConstants.Id: "player-id"]) + XCTAssertEqual(extras, ["episode_id": "extras-id"]) + } + + /// Standard metadata (title, artist, artwork) lives in the same array and + /// must not be mistaken for ours. + func testIgnoresItemsOutsideTheNamespace() throws { + let title = AVMutableMetadataItem() + title.identifier = .commonIdentifierTitle + title.value = "Episode 1" as NSString + title.extendedLanguageTag = "und" + + let items = [ + try XCTUnwrap(title.copy() as? AVMetadataItem), + try XCTUnwrap(MetadataUtils.metadataItem( + identifier: PlayerMetadataConstants.IsOffline, + value: "true" as NSString, + namespace: .BccmPlayer)), + ] + + let parsed = MetadataUtils.getNamespacedMetadata(items, namespace: .BccmPlayer) + + XCTAssertEqual(parsed, [PlayerMetadataConstants.IsOffline: "true"]) + } + + /// Values are read as strings; anything else is skipped rather than crashing + /// on a force-cast. + func testSkipsNonStringValues() throws { + let items = [ + try XCTUnwrap(MetadataUtils.metadataItem( + identifier: "numeric", + value: NSNumber(value: 42), + namespace: .BccmPlayer)), + try XCTUnwrap(MetadataUtils.metadataItem( + identifier: PlayerMetadataConstants.IsLive, + value: "true" as NSString, + namespace: .BccmPlayer)), + ] + + let parsed = MetadataUtils.getNamespacedMetadata(items, namespace: .BccmPlayer) + + XCTAssertNil(parsed["numeric"]) + XCTAssertEqual(parsed[PlayerMetadataConstants.IsLive], "true") + } + + func testNilValueProducesNoItem() { + XCTAssertNil(MetadataUtils.metadataItem( + identifier: PlayerMetadataConstants.IsLive, + value: nil, + namespace: .BccmPlayer)) + } + + func testEmptyInputProducesEmptyOutput() { + XCTAssertTrue(MetadataUtils.getNamespacedMetadata([], namespace: .BccmPlayer).isEmpty) + } + + /// The namespace strings are a wire contract with anything else reading this + /// metadata, so they are pinned rather than derived. + func testNamespaceValues() { + XCTAssertEqual(MetadataNamespace.BccmPlayer.rawValue, "media.bcc.player") + XCTAssertEqual(MetadataNamespace.BccmExtras.rawValue, "media.bcc.extras") + } +} diff --git a/example/pubspec.lock b/example/pubspec.lock index b685898..c6ea7d5 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -23,7 +23,7 @@ packages: path: ".." relative: true source: path - version: "2.0.0" + version: "2.1.0" boolean_selector: dependency: transitive description: