Skip to content

Commit fc198e7

Browse files
yinnhoclaude
andauthored
Add protocol-level integration tests against a fake DLNA renderer (#10)
Spin up an in-process fake renderer (NanoHTTPD serving a device description and AVTransport/RenderingControl SOAP endpoints that record every call) and drive the real control stack against it: description parsing over HTTP, control actions, seek/volume request building, response parsing, playMediaDirect sequencing, error handling, cache fetch/hit/interpolation behavior and the DLNACast facade lifecycle. Unit tests now stub android.util.Log via isReturnDefaultValues. Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 39dd48a commit fc198e7

7 files changed

Lines changed: 527 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### ✨ Added
11+
- **Protocol-level integration tests**: an in-process fake DLNA renderer (local HTTP + SOAP endpoints) drives the full control flow — device description parsing over HTTP, AVTransport/RenderingControl actions, seek/volume request building, response parsing, `playMediaDirect` sequencing, device-error handling and cache behavior (fetch, cache hit, interpolation only while `PLAYING`); the `DLNACast` facade is covered end to end (neutral defaults before `init`, lifecycle with a WifiManager-less context, fast-failure paths). Suite grows to 92 tests
1112
- **Unit tests**: the project's first test suite (69 tests) covers DLNA time parsing/formatting, SOAP XML value extraction (position/volume/mute), DIDL-Lite metadata construction (MIME/class detection, XML escaping, subtitle resources, verbatim override), MIME type mapping and SSDP header parsing — making the CI `test` step meaningful
1213

1314
### 🔧 Changed

app/build.gradle.kts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,13 @@ android {
3232

3333
// Test configuration
3434
testOptions {
35-
unitTests.all {
36-
it.enabled = true
37-
it.useJUnitPlatform()
35+
unitTests {
36+
// Library code logs through android.util.Log; unit tests only
37+
// need the calls to be no-ops
38+
isReturnDefaultValues = true
39+
all {
40+
it.useJUnitPlatform()
41+
}
3842
}
3943
}
4044

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package com.yinnho.upnpcast
2+
3+
import android.content.Context
4+
import kotlinx.coroutines.runBlocking
5+
import org.junit.jupiter.api.Test
6+
import org.mockito.Mockito
7+
import org.junit.jupiter.api.Assertions.assertEquals
8+
import org.junit.jupiter.api.Assertions.assertFalse
9+
import org.junit.jupiter.api.Assertions.assertNull
10+
import org.junit.jupiter.api.Assertions.assertThrows
11+
import org.junit.jupiter.api.Assertions.assertTrue
12+
13+
/**
14+
* Facade lifecycle: neutral defaults before init, graceful behavior with a
15+
* context that has no WifiManager (unit test environment).
16+
*
17+
* All assertions live in one method because DLNACast is a singleton —
18+
* test order inside the method is the only reliable ordering.
19+
*/
20+
class DLNACastTest {
21+
22+
@Test
23+
fun facadeLifecycle() = runBlocking {
24+
DLNACast.cleanup()
25+
26+
// Not initialized: neutral defaults, no hangs
27+
assertEquals(DLNACast.PlaybackState.IDLE, DLNACast.getPlaybackState())
28+
assertTrue(DLNACast.search(100).isEmpty())
29+
assertFalse(DLNACast.play())
30+
assertFalse(DLNACast.pause())
31+
assertFalse(DLNACast.setVolume(50))
32+
assertFalse(DLNACast.cast("http://media/movie.mp4", "T"))
33+
assertNull(DLNACast.getProgress())
34+
assertNull(DLNACast.getVolume())
35+
assertFalse(DLNACast.refreshVolumeCache())
36+
37+
val state = DLNACast.getState()
38+
assertFalse(state.isConnected)
39+
assertNull(state.currentDevice)
40+
assertEquals(DLNACast.PlaybackState.IDLE, state.playbackState)
41+
42+
val notInitialized = assertThrows(com.yinnho.upnpcast.internal.UPnPException.UnknownError::class.java) {
43+
runBlocking {
44+
DLNACast.castLocalFile("/no/such/file.mp4", DLNACast.Device("id", "n", "a", false))
45+
}
46+
}
47+
assertTrue(notInitialized.message!!.contains("not initialized"))
48+
49+
// Initialized with a WifiManager-less context: engine comes up
50+
val context = Mockito.mock(Context::class.java)
51+
Mockito.`when`(context.applicationContext).thenReturn(context)
52+
DLNACast.init(context)
53+
54+
val initializedState = DLNACast.getState()
55+
assertFalse(initializedState.isConnected)
56+
assertEquals(DLNACast.PlaybackState.IDLE, initializedState.playbackState)
57+
58+
// Unknown device fails fast with false, not an exception
59+
assertFalse(DLNACast.castToDevice(DLNACast.Device("nope", "n", "a", false), "http://m/v.mp4", "T"))
60+
assertFalse(DLNACast.control(DLNACast.MediaAction.PLAY))
61+
62+
// cleanup restores the neutral state
63+
DLNACast.cleanup()
64+
assertEquals(DLNACast.PlaybackState.IDLE, DLNACast.getPlaybackState())
65+
assertFalse(DLNACast.getState().isConnected)
66+
}
67+
68+
@Test
69+
fun scanLocalVideosWithoutPermissionReturnsEmptyList() = runBlocking {
70+
// The mocked context's ContentResolver is null; the scanner must
71+
// degrade to an empty result rather than crash
72+
val context = Mockito.mock(Context::class.java)
73+
val videos = DLNACast.scanLocalVideos(context)
74+
assertTrue(videos.isEmpty())
75+
}
76+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
package com.yinnho.upnpcast.internal.core
2+
3+
import com.yinnho.upnpcast.internal.discovery.RemoteDevice
4+
import com.yinnho.upnpcast.internal.discovery.ServiceInfo
5+
import com.yinnho.upnpcast.internal.media.DlnaMediaController
6+
import com.yinnho.upnpcast.internal.media.FakeDlnaRenderer
7+
import kotlinx.coroutines.CoroutineScope
8+
import kotlinx.coroutines.Dispatchers
9+
import kotlinx.coroutines.SupervisorJob
10+
import kotlinx.coroutines.cancel
11+
import kotlinx.coroutines.runBlocking
12+
import org.junit.jupiter.api.AfterEach
13+
import org.junit.jupiter.api.Assertions.assertEquals
14+
import org.junit.jupiter.api.Assertions.assertNotNull
15+
import org.junit.jupiter.api.Assertions.assertNull
16+
import org.junit.jupiter.api.Assertions.assertTrue
17+
import org.junit.jupiter.api.BeforeEach
18+
import org.junit.jupiter.api.Test
19+
20+
class CacheManagerTest {
21+
22+
private lateinit var renderer: FakeDlnaRenderer
23+
private lateinit var controller: DlnaMediaController
24+
private lateinit var scope: CoroutineScope
25+
private lateinit var cacheManager: CacheManager
26+
27+
@BeforeEach
28+
fun setUp() {
29+
renderer = FakeDlnaRenderer().startServer()
30+
controller = DlnaMediaController(deviceFor(renderer))
31+
scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
32+
cacheManager = CacheManager(scope)
33+
}
34+
35+
@AfterEach
36+
fun tearDown() {
37+
cacheManager.clearAll()
38+
scope.cancel()
39+
controller.release()
40+
renderer.stop()
41+
}
42+
43+
private fun deviceFor(renderer: FakeDlnaRenderer) = RemoteDevice(
44+
id = renderer.descriptionUrl,
45+
displayName = "Test Renderer",
46+
address = "127.0.0.1",
47+
locationUrl = renderer.descriptionUrl,
48+
services = listOf(
49+
ServiceInfo("urn:schemas-upnp-org:service:AVTransport:1", "a", "/control/AVTransport", "/e", "/s"),
50+
ServiceInfo("urn:schemas-upnp-org:service:RenderingControl:1", "r", "/control/RenderingControl", "/e", "/s")
51+
)
52+
)
53+
54+
@Test
55+
fun queriesWithoutControllerReturnNull() = runBlocking {
56+
assertNull(cacheManager.getProgress(null))
57+
assertNull(cacheManager.getVolume(null))
58+
}
59+
60+
@Test
61+
fun progressIsFetchedAndThenServedFromCache() = runBlocking {
62+
val first = cacheManager.getProgress(controller)
63+
assertEquals(Pair(10000L, 600000L), first)
64+
65+
val positionCallsAfterFirst = renderer.callsFor("GetPositionInfo").size
66+
val second = cacheManager.getProgress(controller)
67+
assertNotNull(second)
68+
assertEquals(positionCallsAfterFirst, renderer.callsFor("GetPositionInfo").size)
69+
}
70+
71+
@Test
72+
fun progressInterpolatesOnlyWhilePlaying() = runBlocking {
73+
assertTrue(cacheManager.refreshProgressCache(controller))
74+
assertEquals("PLAYING", cacheManager.cachedTransportState)
75+
76+
// Second read inside the cache window advances the position
77+
val interpolated = cacheManager.getProgress(controller)!!
78+
assertTrue(interpolated.first >= 10000L)
79+
assertEquals(600000L, interpolated.second)
80+
81+
// A paused device must not advance
82+
renderer.transportState = "PAUSED_PLAYBACK"
83+
assertTrue(cacheManager.refreshProgressCache(controller))
84+
val paused = cacheManager.getProgress(controller)!!
85+
assertTrue(paused.first < 11000L)
86+
}
87+
88+
@Test
89+
fun volumeIsCachedWithMuteState() = runBlocking {
90+
assertEquals(Pair(30, false), cacheManager.getVolume(controller))
91+
92+
val volumeCallsAfterFirst = renderer.callsFor("GetVolume").size
93+
assertEquals(Pair(30, false), cacheManager.getVolume(controller))
94+
assertEquals(volumeCallsAfterFirst, renderer.callsFor("GetVolume").size)
95+
}
96+
97+
@Test
98+
fun clearAllResetsCachedState() = runBlocking {
99+
cacheManager.refreshProgressCache(controller)
100+
cacheManager.refreshVolumeCache(controller)
101+
102+
cacheManager.clearAll()
103+
104+
assertNull(cacheManager.cachedTransportState)
105+
assertEquals(-1, cacheManager.getVolumeState().first)
106+
}
107+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
package com.yinnho.upnpcast.internal.discovery
2+
3+
import kotlinx.coroutines.runBlocking
4+
import org.junit.jupiter.api.AfterEach
5+
import org.junit.jupiter.api.Assertions.assertEquals
6+
import org.junit.jupiter.api.Assertions.assertNull
7+
import org.junit.jupiter.api.Assertions.assertTrue
8+
import org.junit.jupiter.api.BeforeEach
9+
import org.junit.jupiter.api.Test
10+
11+
/**
12+
* Parses a real device description document fetched over HTTP from an
13+
* in-process fake renderer
14+
*/
15+
class DeviceDescriptionParserTest {
16+
17+
private lateinit var renderer: com.yinnho.upnpcast.internal.media.FakeDlnaRenderer
18+
private val parser = DeviceDescriptionParser()
19+
20+
@BeforeEach
21+
fun setUp() {
22+
renderer = com.yinnho.upnpcast.internal.media.FakeDlnaRenderer().startServer()
23+
}
24+
25+
@AfterEach
26+
fun tearDown() {
27+
renderer.stop()
28+
}
29+
30+
@Test
31+
fun parsesDescriptionOverHttp() = runBlocking {
32+
val info = parser.parseDeviceDescription(renderer.descriptionUrl)
33+
34+
assertEquals("Test Renderer", info?.friendlyName)
35+
assertEquals("UnitTest Corp", info?.manufacturer)
36+
assertEquals("TestModel 1000", info?.modelName)
37+
assertEquals(2, info?.services?.size)
38+
39+
val avTransport = info?.services?.first { it.serviceType.contains("AVTransport") }
40+
assertEquals("/control/AVTransport", avTransport?.controlURL)
41+
}
42+
43+
@Test
44+
fun createEnhancedDeviceMapsTypedFields() {
45+
val device = parser.createEnhancedDevice(
46+
id = renderer.descriptionUrl,
47+
address = "127.0.0.1",
48+
locationUrl = renderer.descriptionUrl,
49+
deviceInfo = DeviceDescriptionParser.DeviceInfo(
50+
friendlyName = "F",
51+
manufacturer = "M",
52+
modelName = "MM",
53+
deviceType = "T",
54+
services = listOf(
55+
ServiceInfo("urn:schemas-upnp-org:service:AVTransport:1", "id", "/c", "/e", "/s")
56+
)
57+
)
58+
)
59+
60+
assertEquals("F", device.displayName)
61+
assertEquals("M", device.manufacturer)
62+
assertEquals("MM", device.model)
63+
assertEquals(renderer.descriptionUrl, device.locationUrl)
64+
assertEquals(1, device.services.size)
65+
assertEquals("/c", device.services.single().controlURL)
66+
}
67+
68+
@Test
69+
fun createEnhancedDeviceFallsBackToDefaults() {
70+
val device = parser.createEnhancedDevice(
71+
id = "id",
72+
address = "1.2.3.4",
73+
locationUrl = "http://1.2.3.4/desc",
74+
deviceInfo = null
75+
)
76+
77+
assertEquals("DLNA Device", device.displayName)
78+
assertEquals("Unknown", device.manufacturer)
79+
assertTrue(device.services.isEmpty())
80+
}
81+
82+
@Test
83+
fun unreachableDescriptionReturnsNull() = runBlocking {
84+
// 404 path retries 3 times before giving up (~3s of backoff)
85+
assertNull(parser.parseDeviceDescription("${renderer.baseUrl}/nope"))
86+
}
87+
}

0 commit comments

Comments
 (0)