@mentra/acs-meeting 3.2.0-dev.120
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +20 -0
- package/README.md +412 -0
- package/THIRD_PARTY +7 -0
- package/android/build.gradle +64 -0
- package/android/src/main/AndroidManifest.xml +5 -0
- package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingModule.kt +80 -0
- package/android/src/main/java/com/mentra/acsmeeting/AcsMeetingSession.kt +846 -0
- package/android/src/main/java/com/mentra/acsmeeting/RemoteRoster.kt +123 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/AcsAudioPolicy.kt +90 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/AudioPolicyApplier.kt +176 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/IncomingAudioPump.kt +112 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/IncomingRateProbe.kt +65 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/PcmBridge.kt +108 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/PcmResampler.kt +159 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/PhoneMicCapturer.kt +115 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkPacer.kt +302 -0
- package/android/src/main/java/com/mentra/acsmeeting/audio/UplinkSender.kt +230 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/BitmapFont.kt +99 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/CloudflareWhepSource.kt +578 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/FirstFrameGate.kt +45 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/GlassesMediaSource.kt +101 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/MediaListeners.kt +57 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/SyntheticFrameFactory.kt +205 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/SyntheticI420Source.kt +214 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/TrackRegistry.kt +31 -0
- package/android/src/main/java/com/mentra/acsmeeting/source/VideoSourceArm.kt +41 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/ChromaProbe.kt +78 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineStats.kt +281 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/PipelineTicker.kt +51 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/ProcessCpu.kt +15 -0
- package/android/src/main/java/com/mentra/acsmeeting/telemetry/RingPercentile.kt +33 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/AcsFrameSender.kt +475 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/AcsTimestamp.kt +26 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/FrameGeometry.kt +22 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/I420FormatSpec.kt +90 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/I420Packer.kt +72 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/Nv12FormatSpec.kt +36 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/Nv12Packer.kt +51 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/SendGate.kt +27 -0
- package/android/src/main/java/com/mentra/acsmeeting/video/VideoProfile.kt +57 -0
- package/android/src/test/java/com/mentra/acsmeeting/RemoteRosterTest.kt +40 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/AcsAudioPolicyTest.kt +204 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/AudioPolicyApplierTest.kt +223 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/CapturePolicyTest.kt +12 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/IncomingAudioPumpTest.kt +97 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/IncomingRateProbeTest.kt +78 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/PcmResamplerTest.kt +125 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/UplinkPacerTest.kt +201 -0
- package/android/src/test/java/com/mentra/acsmeeting/audio/UplinkSenderTest.kt +166 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/AcsInvestigationTest.kt +26 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/BitmapFontTest.kt +22 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/FirstFrameGateTest.kt +60 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/GlassesMediaSourceTest.kt +133 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/I420PlanesTest.kt +67 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/SyntheticFrameFactoryTest.kt +193 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/SyntheticI420SourceTest.kt +176 -0
- package/android/src/test/java/com/mentra/acsmeeting/source/TrackRegistryTest.kt +36 -0
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/ChromaProbeTest.kt +61 -0
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/PipelineStatsTest.kt +256 -0
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/ProcessCpuTest.kt +18 -0
- package/android/src/test/java/com/mentra/acsmeeting/telemetry/RingPercentileTest.kt +49 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/AcsTimestampTest.kt +43 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/FrameGeometryTest.kt +33 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/I420FormatSpecTest.kt +77 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/I420PackerTest.kt +126 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/Nv12FormatSpecTest.kt +36 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/Nv12PackerTest.kt +58 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/SendGateTest.kt +79 -0
- package/android/src/test/java/com/mentra/acsmeeting/video/VideoProfileTest.kt +68 -0
- package/build/AcsMeeting.types.d.ts +59 -0
- package/build/AcsMeeting.types.d.ts.map +1 -0
- package/build/AcsMeeting.types.js +2 -0
- package/build/AcsMeeting.types.js.map +1 -0
- package/build/AcsMeetingModule.d.ts +15 -0
- package/build/AcsMeetingModule.d.ts.map +1 -0
- package/build/AcsMeetingModule.js +3 -0
- package/build/AcsMeetingModule.js.map +1 -0
- package/build/AcsMeetingModule.web.d.ts +16 -0
- package/build/AcsMeetingModule.web.d.ts.map +1 -0
- package/build/AcsMeetingModule.web.js +31 -0
- package/build/AcsMeetingModule.web.js.map +1 -0
- package/build/index.d.ts +3 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +3 -0
- package/build/index.js.map +1 -0
- package/expo-module.config.json +9 -0
- package/ios/AcsFrameSender.swift +47 -0
- package/ios/AcsMeeting.podspec +27 -0
- package/ios/AcsMeetingModule.swift +558 -0
- package/ios/PcmBridge.swift +117 -0
- package/ios/PhoneMicCapturer.swift +64 -0
- package/ios/PolicyKit/Package.swift +17 -0
- package/ios/PolicyKit/Sources/AcsAudioPolicy/AcsAudioPolicy.swift +102 -0
- package/ios/PolicyKit/Sources/AcsAudioPolicy/AudioPolicyApplier.swift +162 -0
- package/ios/PolicyKit/Sources/AcsAudioPolicy/GlassesMediaSource.swift +68 -0
- package/ios/PolicyKit/Tests/AcsAudioPolicyTests/AcsAudioPolicyTests.swift +273 -0
- package/ios/WhepVideoSource.swift +356 -0
- package/package.json +56 -0
- package/src/AcsMeeting.types.ts +66 -0
- package/src/AcsMeetingModule.ts +16 -0
- package/src/AcsMeetingModule.web.ts +33 -0
- package/src/index.ts +2 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
package com.mentra.acsmeeting.source
|
|
2
|
+
|
|
3
|
+
import org.assertj.core.api.Assertions.assertThat
|
|
4
|
+
import org.junit.Test
|
|
5
|
+
import java.nio.ByteBuffer
|
|
6
|
+
|
|
7
|
+
class BitmapFontTest {
|
|
8
|
+
@Test
|
|
9
|
+
fun drawsOnPixelsForDigitEight() {
|
|
10
|
+
val width = 80
|
|
11
|
+
val height = 40
|
|
12
|
+
val dest = ByteBuffer.allocate(width * height)
|
|
13
|
+
BitmapFont.draw(dest, width, height, "8", 0, 0, 2, 0xEB.toByte())
|
|
14
|
+
var on = 0
|
|
15
|
+
for (i in 0 until dest.capacity()) {
|
|
16
|
+
if (dest.get(i) == 0xEB.toByte()) on += 1
|
|
17
|
+
}
|
|
18
|
+
assertThat(on).isGreaterThan(0)
|
|
19
|
+
assertThat(BitmapFont.textWidth(1, 2)).isEqualTo(5 * 2)
|
|
20
|
+
assertThat(BitmapFont.textHeight(2)).isEqualTo(7 * 2)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
package com.mentra.acsmeeting.source
|
|
2
|
+
|
|
3
|
+
import org.assertj.core.api.Assertions.assertThat
|
|
4
|
+
import org.junit.Test
|
|
5
|
+
|
|
6
|
+
class FirstFrameGateTest {
|
|
7
|
+
@Test
|
|
8
|
+
fun answerAloneDoesNotPromote() {
|
|
9
|
+
val gate = FirstFrameGate()
|
|
10
|
+
gate.arm(1)
|
|
11
|
+
// The answer is armed, not live: nothing has reached the sink yet.
|
|
12
|
+
assertThat(gate.expired(1)).isTrue()
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
@Test
|
|
16
|
+
fun firstFrameForTheArmedGenerationPromotesExactlyOnce() {
|
|
17
|
+
val gate = FirstFrameGate()
|
|
18
|
+
gate.arm(7)
|
|
19
|
+
assertThat(gate.onFrame(7)).isTrue()
|
|
20
|
+
assertThat(gate.onFrame(7)).isFalse()
|
|
21
|
+
assertThat(gate.expired(7)).isFalse()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@Test
|
|
25
|
+
fun framesFromAPeerBeingTornDownNeverPromote() {
|
|
26
|
+
val gate = FirstFrameGate()
|
|
27
|
+
gate.arm(2)
|
|
28
|
+
assertThat(gate.onFrame(1)).isFalse()
|
|
29
|
+
assertThat(gate.expired(2)).isTrue()
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
@Test
|
|
33
|
+
fun deadlineForASupersededGenerationDoesNotFailTheNewPeer() {
|
|
34
|
+
val gate = FirstFrameGate()
|
|
35
|
+
gate.arm(3)
|
|
36
|
+
gate.arm(4)
|
|
37
|
+
assertThat(gate.expired(3)).isFalse()
|
|
38
|
+
assertThat(gate.expired(4)).isTrue()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@Test
|
|
42
|
+
fun iceRecoveryRearmsSoTheNextFramePromotesAgain() {
|
|
43
|
+
val gate = FirstFrameGate()
|
|
44
|
+
gate.arm(5)
|
|
45
|
+
assertThat(gate.onFrame(5)).isTrue()
|
|
46
|
+
// ICE bounced and came back on the same peer: LIVE has to be re-earned.
|
|
47
|
+
gate.arm(5)
|
|
48
|
+
assertThat(gate.expired(5)).isTrue()
|
|
49
|
+
assertThat(gate.onFrame(5)).isTrue()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@Test
|
|
53
|
+
fun resetClearsTheArmSoAStoppedSourceCannotBeFailedLater() {
|
|
54
|
+
val gate = FirstFrameGate()
|
|
55
|
+
gate.arm(6)
|
|
56
|
+
gate.reset()
|
|
57
|
+
assertThat(gate.expired(6)).isFalse()
|
|
58
|
+
assertThat(gate.onFrame(6)).isFalse()
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
package com.mentra.acsmeeting.source
|
|
2
|
+
|
|
3
|
+
import org.assertj.core.api.Assertions.assertThat
|
|
4
|
+
import org.junit.Test
|
|
5
|
+
|
|
6
|
+
class FakeGlassesMediaSource : GlassesMediaSource {
|
|
7
|
+
val started = mutableListOf<SourceConfig>()
|
|
8
|
+
val restarted = mutableListOf<SourceConfig>()
|
|
9
|
+
var stopped = 0
|
|
10
|
+
var pcmEnabled = true
|
|
11
|
+
var pcmListener: PcmListener? = null
|
|
12
|
+
val targetSizes = mutableListOf<TargetSize?>()
|
|
13
|
+
override var state: SourceState = SourceState.IDLE
|
|
14
|
+
private set
|
|
15
|
+
|
|
16
|
+
override fun start(config: SourceConfig) {
|
|
17
|
+
started.add(config)
|
|
18
|
+
state = SourceState.CONNECTING
|
|
19
|
+
state = SourceState.LIVE
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
override fun restart(config: SourceConfig) {
|
|
23
|
+
restarted.add(config)
|
|
24
|
+
start(config)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
override fun stop() {
|
|
28
|
+
stopped += 1
|
|
29
|
+
state = SourceState.IDLE
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
override fun setPcmDeliveryEnabled(enabled: Boolean) {
|
|
33
|
+
pcmEnabled = enabled
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
override fun setTargetSize(size: TargetSize?) {
|
|
37
|
+
targetSizes.add(size)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
fun emitPcm(pcm: ByteArray, sampleRate: Int = 16_000, channels: Int = 1) {
|
|
41
|
+
if (pcmEnabled && state == SourceState.LIVE) {
|
|
42
|
+
pcmListener?.onPcm(pcm, sampleRate, channels)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
class GlassesMediaSourceContractTest {
|
|
48
|
+
private fun contract(source: FakeGlassesMediaSource) {
|
|
49
|
+
val pcm = mutableListOf<ByteArray>()
|
|
50
|
+
source.pcmListener = PcmListener { bytes, _, _ -> pcm.add(bytes) }
|
|
51
|
+
val controller = GlassesMediaController { _, listener ->
|
|
52
|
+
source.pcmListener = listener
|
|
53
|
+
source
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
assertThat(controller.state).isEqualTo(SourceState.IDLE)
|
|
57
|
+
controller.attach(
|
|
58
|
+
video = VideoFrameListener { _ -> },
|
|
59
|
+
pcm = { bytes, _, _ -> pcm.add(bytes) },
|
|
60
|
+
config = SourceConfig("https://example.com/whep"),
|
|
61
|
+
)
|
|
62
|
+
assertThat(source.started).containsExactly(SourceConfig("https://example.com/whep"))
|
|
63
|
+
assertThat(controller.state).isEqualTo(SourceState.LIVE)
|
|
64
|
+
|
|
65
|
+
source.emitPcm(byteArrayOf(1, 2, 3, 4))
|
|
66
|
+
assertThat(pcm).hasSize(1)
|
|
67
|
+
|
|
68
|
+
controller.setPcmDeliveryEnabled(false)
|
|
69
|
+
source.emitPcm(byteArrayOf(9, 9))
|
|
70
|
+
assertThat(pcm).hasSize(1)
|
|
71
|
+
|
|
72
|
+
controller.restart(SourceConfig("https://example.com/whep-2"))
|
|
73
|
+
assertThat(source.restarted.map { it.url }).contains("https://example.com/whep-2")
|
|
74
|
+
assertThat(controller.state).isEqualTo(SourceState.LIVE)
|
|
75
|
+
|
|
76
|
+
controller.setTargetSize(TargetSize(1280, 720))
|
|
77
|
+
assertThat(source.targetSizes).contains(TargetSize(1280, 720))
|
|
78
|
+
|
|
79
|
+
controller.stop()
|
|
80
|
+
assertThat(source.stopped).isGreaterThanOrEqualTo(1)
|
|
81
|
+
assertThat(controller.state).isEqualTo(SourceState.IDLE)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
@Test
|
|
85
|
+
fun fakeSourceHonorsStartRestartStopPcmAndState() {
|
|
86
|
+
contract(FakeGlassesMediaSource())
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
@Test
|
|
90
|
+
fun sameUrlIsReusedUntilTheSubscriberFails() {
|
|
91
|
+
val url = "https://example.com/whep"
|
|
92
|
+
val config = SourceConfig(url)
|
|
93
|
+
// A resumed glasses uplink re-sends the same URL. ACS never lost its
|
|
94
|
+
// subscription, so rebuilding would only restart the wait for a first frame.
|
|
95
|
+
assertThat(canReuseSource(url, SourceState.LIVE, config)).isTrue()
|
|
96
|
+
// Includes the answer → first frame window, which LIVE no longer covers.
|
|
97
|
+
assertThat(canReuseSource(url, SourceState.CONNECTING, config)).isTrue()
|
|
98
|
+
// ICE dropped, or the answer never produced a frame: rebuild.
|
|
99
|
+
assertThat(canReuseSource(url, SourceState.FAILED, config)).isFalse()
|
|
100
|
+
// A new stream means a new WHEP URL, and a stopped source has none.
|
|
101
|
+
assertThat(canReuseSource(url, SourceState.LIVE, SourceConfig("https://example.com/whep-2"))).isFalse()
|
|
102
|
+
assertThat(canReuseSource(null, SourceState.IDLE, config)).isFalse()
|
|
103
|
+
assertThat(canReuseSource(url, SourceState.LIVE, SourceConfig(url, SourceKind.DIRECT))).isFalse()
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
@Test
|
|
107
|
+
fun syntheticSourceHonorsStartRestartStopAndState() {
|
|
108
|
+
val source = SyntheticI420Source(
|
|
109
|
+
video = { _ -> },
|
|
110
|
+
stats = com.mentra.acsmeeting.telemetry.PipelineStats(),
|
|
111
|
+
isReady = { false },
|
|
112
|
+
)
|
|
113
|
+
val controller = GlassesMediaController { _, _ -> source }
|
|
114
|
+
|
|
115
|
+
assertThat(controller.state).isEqualTo(SourceState.IDLE)
|
|
116
|
+
controller.attach(
|
|
117
|
+
video = { _ -> },
|
|
118
|
+
pcm = { _, _, _ -> },
|
|
119
|
+
config = SourceConfig("https://example.com/whep", SourceKind.DIRECT),
|
|
120
|
+
)
|
|
121
|
+
assertThat(controller.state).isEqualTo(SourceState.LIVE)
|
|
122
|
+
|
|
123
|
+
controller.setPcmDeliveryEnabled(false)
|
|
124
|
+
assertThat(source.pcmDeliveryEnabled()).isFalse()
|
|
125
|
+
|
|
126
|
+
controller.restart(SourceConfig("https://example.com/whep-2", SourceKind.DIRECT))
|
|
127
|
+
assertThat(controller.state).isEqualTo(SourceState.LIVE)
|
|
128
|
+
|
|
129
|
+
controller.setTargetSize(TargetSize(1280, 720))
|
|
130
|
+
controller.stop()
|
|
131
|
+
assertThat(controller.state).isEqualTo(SourceState.IDLE)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
package com.mentra.acsmeeting.source
|
|
2
|
+
|
|
3
|
+
import com.mentra.acsmeeting.video.I420Packer
|
|
4
|
+
import org.assertj.core.api.Assertions.assertThat
|
|
5
|
+
import org.junit.Test
|
|
6
|
+
import java.nio.ByteBuffer
|
|
7
|
+
|
|
8
|
+
class I420PlanesTest {
|
|
9
|
+
@Test
|
|
10
|
+
fun tightWhenStridesMatchWidth() {
|
|
11
|
+
val planes = planes(width = 8, height = 4, strideY = 8, strideUv = 4, direct = false)
|
|
12
|
+
assertThat(planes.isTight()).isTrue()
|
|
13
|
+
assertThat(planes.planesReadable()).isTrue()
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
@Test
|
|
17
|
+
fun paddedWhenStrideExceedsWidth() {
|
|
18
|
+
val planes = planes(width = 8, height = 4, strideY = 16, strideUv = 8, direct = false)
|
|
19
|
+
assertThat(planes.isTight()).isFalse()
|
|
20
|
+
assertThat(planes.planesReadable()).isTrue()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
@Test
|
|
24
|
+
fun heapBuffersAreNotDirect() {
|
|
25
|
+
val planes = planes(width = 4, height = 4, strideY = 4, strideUv = 2, direct = false)
|
|
26
|
+
assertThat(planes.isDirect()).isFalse()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
@Test
|
|
30
|
+
fun directBuffersReportDirect() {
|
|
31
|
+
val planes = planes(width = 4, height = 4, strideY = 4, strideUv = 2, direct = true)
|
|
32
|
+
assertThat(planes.isDirect()).isTrue()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
@Test
|
|
36
|
+
fun shortPlaneIsNotReadable() {
|
|
37
|
+
val y = ByteBuffer.allocate(4)
|
|
38
|
+
val u = ByteBuffer.allocate(2)
|
|
39
|
+
val v = ByteBuffer.allocate(2)
|
|
40
|
+
val planes = I420Planes(y, 8, u, 4, v, 4, 8, 4, 0L)
|
|
41
|
+
assertThat(planes.planesReadable()).isFalse()
|
|
42
|
+
assertThat(I420Packer.planeMinBytes(8, 8, 4)).isEqualTo(32)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
private fun planes(
|
|
46
|
+
width: Int,
|
|
47
|
+
height: Int,
|
|
48
|
+
strideY: Int,
|
|
49
|
+
strideUv: Int,
|
|
50
|
+
direct: Boolean,
|
|
51
|
+
): I420Planes {
|
|
52
|
+
val chromaH = I420Packer.chromaStride(height)
|
|
53
|
+
fun alloc(bytes: Int): ByteBuffer =
|
|
54
|
+
if (direct) ByteBuffer.allocateDirect(bytes) else ByteBuffer.allocate(bytes)
|
|
55
|
+
return I420Planes(
|
|
56
|
+
y = alloc(strideY * height),
|
|
57
|
+
strideY = strideY,
|
|
58
|
+
u = alloc(strideUv * chromaH),
|
|
59
|
+
strideU = strideUv,
|
|
60
|
+
v = alloc(strideUv * chromaH),
|
|
61
|
+
strideV = strideUv,
|
|
62
|
+
width = width,
|
|
63
|
+
height = height,
|
|
64
|
+
timestampNs = 0L,
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
package com.mentra.acsmeeting.source
|
|
2
|
+
|
|
3
|
+
import com.mentra.acsmeeting.telemetry.ChromaProbe
|
|
4
|
+
import com.mentra.acsmeeting.video.I420Packer
|
|
5
|
+
import org.assertj.core.api.Assertions.assertThat
|
|
6
|
+
import org.junit.Test
|
|
7
|
+
import java.nio.ByteBuffer
|
|
8
|
+
|
|
9
|
+
class SyntheticFrameFactoryTest {
|
|
10
|
+
@Test
|
|
11
|
+
fun packedLengthMatchesI420() {
|
|
12
|
+
val dest = buffer(WIDTH, HEIGHT)
|
|
13
|
+
SyntheticFrameFactory().write(dest, WIDTH, HEIGHT, 0)
|
|
14
|
+
assertThat(dest.remaining()).isEqualTo(I420Packer.packedSize(WIDTH, HEIGHT))
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Regression for the 1.4 fps wire collapse: a noisy CHEAP background is
|
|
19
|
+
* incompressible and starves the ACS encoder. CHEAP must stay flat.
|
|
20
|
+
*/
|
|
21
|
+
@Test
|
|
22
|
+
fun cheapBackgroundStaysCompressible() {
|
|
23
|
+
val dest = buffer(WIDTH, HEIGHT)
|
|
24
|
+
SyntheticFrameFactory(SyntheticEntropy.CHEAP).write(dest, WIDTH, HEIGHT, 0)
|
|
25
|
+
val y = yBytes(dest)
|
|
26
|
+
var background = 0
|
|
27
|
+
for (row in HEIGHT - 40 until HEIGHT) {
|
|
28
|
+
val rowOff = row * WIDTH
|
|
29
|
+
for (col in 0 until WIDTH) {
|
|
30
|
+
if (y[rowOff + col] == SyntheticFrameFactory.BG_Y) background += 1
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
assertThat(background).isGreaterThan(30 * WIDTH)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
@Test
|
|
37
|
+
fun consecutiveCheapFramesDifferInY() {
|
|
38
|
+
val a = buffer(WIDTH, HEIGHT)
|
|
39
|
+
val b = buffer(WIDTH, HEIGHT)
|
|
40
|
+
val factory = SyntheticFrameFactory(SyntheticEntropy.CHEAP)
|
|
41
|
+
factory.write(a, WIDTH, HEIGHT, 0)
|
|
42
|
+
factory.write(b, WIDTH, HEIGHT, 1)
|
|
43
|
+
assertThat(yBytes(a)).isNotEqualTo(yBytes(b))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
@Test
|
|
47
|
+
fun cheapIsDeterministicForAGivenIndex() {
|
|
48
|
+
val a = buffer(WIDTH, HEIGHT)
|
|
49
|
+
val b = buffer(WIDTH, HEIGHT)
|
|
50
|
+
SyntheticFrameFactory(SyntheticEntropy.CHEAP).write(a, WIDTH, HEIGHT, 7)
|
|
51
|
+
SyntheticFrameFactory(SyntheticEntropy.CHEAP).write(b, WIDTH, HEIGHT, 7)
|
|
52
|
+
assertThat(copy(a)).isEqualTo(copy(b))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@Test
|
|
56
|
+
fun incrementalCheapMatchesFreshWrite() {
|
|
57
|
+
val stepped = buffer(WIDTH, HEIGHT)
|
|
58
|
+
val fresh = buffer(WIDTH, HEIGHT)
|
|
59
|
+
val factory = SyntheticFrameFactory(SyntheticEntropy.CHEAP)
|
|
60
|
+
factory.write(stepped, WIDTH, HEIGHT, 0)
|
|
61
|
+
factory.write(stepped, WIDTH, HEIGHT, 1)
|
|
62
|
+
SyntheticFrameFactory(SyntheticEntropy.CHEAP).write(fresh, WIDTH, HEIGHT, 1)
|
|
63
|
+
assertThat(copy(stepped)).isEqualTo(copy(fresh))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@Test
|
|
67
|
+
fun chromaAveragesLandAt128() {
|
|
68
|
+
for (entropy in SyntheticEntropy.entries) {
|
|
69
|
+
val dest = buffer(WIDTH, HEIGHT)
|
|
70
|
+
SyntheticFrameFactory(entropy).write(dest, WIDTH, HEIGHT, 3)
|
|
71
|
+
val sample = ChromaProbe.samplePacked(dest, WIDTH, HEIGHT)
|
|
72
|
+
assertThat(sample.u).isEqualTo(128)
|
|
73
|
+
assertThat(sample.v).isEqualTo(128)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
@Test
|
|
78
|
+
fun motionPansAlmostEveryPixel() {
|
|
79
|
+
val a = buffer(WIDTH, HEIGHT)
|
|
80
|
+
val b = buffer(WIDTH, HEIGHT)
|
|
81
|
+
val factory = SyntheticFrameFactory(SyntheticEntropy.MOTION)
|
|
82
|
+
factory.write(a, WIDTH, HEIGHT, 0)
|
|
83
|
+
factory.write(b, WIDTH, HEIGHT, 1)
|
|
84
|
+
val ya = yBytes(a)
|
|
85
|
+
val yb = yBytes(b)
|
|
86
|
+
var differ = 0
|
|
87
|
+
for (i in ya.indices) if (ya[i] != yb[i]) differ += 1
|
|
88
|
+
assertThat(differ).isGreaterThan(ya.size / 2)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Glasses motion still has neighbors that look alike. A 32×32 patch in the
|
|
93
|
+
* corner must stay a gradient, not snow — that is what lets an I-frame fit
|
|
94
|
+
* the bitrate budget.
|
|
95
|
+
*/
|
|
96
|
+
@Test
|
|
97
|
+
fun motionKeepsSpatialStructure() {
|
|
98
|
+
val dest = buffer(WIDTH, HEIGHT)
|
|
99
|
+
SyntheticFrameFactory(SyntheticEntropy.MOTION).write(dest, WIDTH, HEIGHT, 0)
|
|
100
|
+
val y = yBytes(dest)
|
|
101
|
+
val unique = mutableSetOf<Byte>()
|
|
102
|
+
for (row in HEIGHT - 80 until HEIGHT - 48) {
|
|
103
|
+
val rowOff = row * WIDTH
|
|
104
|
+
for (col in 0 until 32) unique.add(y[rowOff + col])
|
|
105
|
+
}
|
|
106
|
+
assertThat(unique.size).isLessThan(40)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
@Test
|
|
110
|
+
fun motionIsDeterministicForAGivenIndex() {
|
|
111
|
+
val a = buffer(WIDTH, HEIGHT)
|
|
112
|
+
val b = buffer(WIDTH, HEIGHT)
|
|
113
|
+
SyntheticFrameFactory(SyntheticEntropy.MOTION).write(a, WIDTH, HEIGHT, 11)
|
|
114
|
+
SyntheticFrameFactory(SyntheticEntropy.MOTION).write(b, WIDTH, HEIGHT, 11)
|
|
115
|
+
assertThat(copy(a)).isEqualTo(copy(b))
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
@Test
|
|
119
|
+
fun noiseRewritesTheFullYPlane() {
|
|
120
|
+
val a = buffer(WIDTH, HEIGHT)
|
|
121
|
+
val b = buffer(WIDTH, HEIGHT)
|
|
122
|
+
val factory = SyntheticFrameFactory(SyntheticEntropy.NOISE)
|
|
123
|
+
factory.write(a, WIDTH, HEIGHT, 0)
|
|
124
|
+
factory.write(b, WIDTH, HEIGHT, 1)
|
|
125
|
+
val ya = yBytes(a)
|
|
126
|
+
val yb = yBytes(b)
|
|
127
|
+
var differ = 0
|
|
128
|
+
for (i in ya.indices) if (ya[i] != yb[i]) differ += 1
|
|
129
|
+
assertThat(differ).isGreaterThan(ya.size / 2)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
@Test
|
|
133
|
+
fun stampPaintsFrameIdAndFpsOnADarkBox() {
|
|
134
|
+
val dest = buffer(WIDTH, HEIGHT)
|
|
135
|
+
SyntheticFrameFactory(SyntheticEntropy.CHEAP).write(dest, WIDTH, HEIGHT, 42, 15.0)
|
|
136
|
+
val y = yBytes(dest)
|
|
137
|
+
val boxX = WIDTH / 16
|
|
138
|
+
val boxY = HEIGHT / 2 - 80
|
|
139
|
+
var dark = 0
|
|
140
|
+
var bright = 0
|
|
141
|
+
for (row in boxY until boxY + 160) {
|
|
142
|
+
val rowOff = row * WIDTH
|
|
143
|
+
for (col in boxX until boxX + 400) {
|
|
144
|
+
val value = y[rowOff + col].toInt() and 0xFF
|
|
145
|
+
if (value <= 20) dark += 1
|
|
146
|
+
if (value >= 220) bright += 1
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
assertThat(dark).isGreaterThan(1_000)
|
|
150
|
+
assertThat(bright).isGreaterThan(200)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
@Test
|
|
154
|
+
fun stampFrameIndexChangesTheOverlay() {
|
|
155
|
+
val a = buffer(WIDTH, HEIGHT)
|
|
156
|
+
val b = buffer(WIDTH, HEIGHT)
|
|
157
|
+
SyntheticFrameFactory(SyntheticEntropy.CHEAP).write(a, WIDTH, HEIGHT, 1, 15.0)
|
|
158
|
+
SyntheticFrameFactory(SyntheticEntropy.CHEAP).write(b, WIDTH, HEIGHT, 2, 15.0)
|
|
159
|
+
assertThat(copy(a)).isNotEqualTo(copy(b))
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
@Test
|
|
163
|
+
fun noiseIsDeterministicForAGivenIndex() {
|
|
164
|
+
val a = buffer(WIDTH, HEIGHT)
|
|
165
|
+
val b = buffer(WIDTH, HEIGHT)
|
|
166
|
+
SyntheticFrameFactory(SyntheticEntropy.NOISE).write(a, WIDTH, HEIGHT, 11)
|
|
167
|
+
SyntheticFrameFactory(SyntheticEntropy.NOISE).write(b, WIDTH, HEIGHT, 11)
|
|
168
|
+
assertThat(copy(a)).isEqualTo(copy(b))
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private fun buffer(width: Int, height: Int): ByteBuffer =
|
|
172
|
+
ByteBuffer.allocate(I420Packer.packedSize(width, height))
|
|
173
|
+
|
|
174
|
+
private fun yBytes(src: ByteBuffer): ByteArray {
|
|
175
|
+
val view = src.duplicate()
|
|
176
|
+
view.position(0)
|
|
177
|
+
val y = ByteArray(WIDTH * HEIGHT)
|
|
178
|
+
view.get(y)
|
|
179
|
+
return y
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private fun copy(src: ByteBuffer): ByteArray {
|
|
183
|
+
val view = src.duplicate()
|
|
184
|
+
val out = ByteArray(view.remaining())
|
|
185
|
+
view.get(out)
|
|
186
|
+
return out
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
companion object {
|
|
190
|
+
private const val WIDTH = 1280
|
|
191
|
+
private const val HEIGHT = 720
|
|
192
|
+
}
|
|
193
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
package com.mentra.acsmeeting.source
|
|
2
|
+
|
|
3
|
+
import com.mentra.acsmeeting.telemetry.PipelineStats
|
|
4
|
+
import com.mentra.acsmeeting.video.I420Packer
|
|
5
|
+
import org.assertj.core.api.Assertions.assertThat
|
|
6
|
+
import org.junit.Test
|
|
7
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
8
|
+
|
|
9
|
+
class SyntheticI420SourceTest {
|
|
10
|
+
@Test
|
|
11
|
+
fun withheldWhileNotReadyThenEmitsOnceReady() {
|
|
12
|
+
val ready = AtomicBoolean(false)
|
|
13
|
+
val frames = ArrayList<Triple<Int, Int, Int>>()
|
|
14
|
+
val stats = PipelineStats()
|
|
15
|
+
val source = source(stats, ready) { planes ->
|
|
16
|
+
frames.add(Triple(I420Planes.remaining(planes.y) + I420Planes.remaining(planes.u) + I420Planes.remaining(planes.v), planes.width, planes.height))
|
|
17
|
+
}
|
|
18
|
+
source.becomeLive()
|
|
19
|
+
source.setTargetSize(TargetSize(1280, 720))
|
|
20
|
+
|
|
21
|
+
assertThat(source.emitOnce()).isFalse()
|
|
22
|
+
assertThat(frames).isEmpty()
|
|
23
|
+
assertThat(stats.sinkCount()).isEqualTo(0)
|
|
24
|
+
|
|
25
|
+
ready.set(true)
|
|
26
|
+
assertThat(source.emitOnce()).isTrue()
|
|
27
|
+
assertThat(frames).hasSize(1)
|
|
28
|
+
assertThat(frames[0].second).isEqualTo(1280)
|
|
29
|
+
assertThat(frames[0].third).isEqualTo(720)
|
|
30
|
+
assertThat(frames[0].first).isEqualTo(I420Packer.packedSize(1280, 720))
|
|
31
|
+
assertThat(stats.sinkCount()).isEqualTo(1)
|
|
32
|
+
assertThat(source.events.any { it.startsWith("P6 synthetic first-frame") }).isTrue()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
@Test
|
|
36
|
+
fun withheldWhileTargetSizeIsNull() {
|
|
37
|
+
val frames = ArrayList<Int>()
|
|
38
|
+
val stats = PipelineStats()
|
|
39
|
+
val source = source(stats, AtomicBoolean(true)) { _ -> frames.add(1) }
|
|
40
|
+
source.becomeLive()
|
|
41
|
+
|
|
42
|
+
assertThat(source.emitOnce()).isFalse()
|
|
43
|
+
assertThat(frames).isEmpty()
|
|
44
|
+
assertThat(stats.sinkCount()).isEqualTo(0)
|
|
45
|
+
|
|
46
|
+
source.setTargetSize(TargetSize(640, 360))
|
|
47
|
+
assertThat(source.emitOnce()).isTrue()
|
|
48
|
+
assertThat(frames).hasSize(1)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@Test
|
|
52
|
+
fun nReadyCallsDeliverNFramesAndHonorResize() {
|
|
53
|
+
val frames = ArrayList<Pair<Int, Int>>()
|
|
54
|
+
val stats = PipelineStats()
|
|
55
|
+
val source = source(stats, AtomicBoolean(true)) { planes -> frames.add(planes.width to planes.height) }
|
|
56
|
+
source.becomeLive()
|
|
57
|
+
source.setTargetSize(TargetSize(1280, 720))
|
|
58
|
+
repeat(4) { source.emitOnce() }
|
|
59
|
+
source.setTargetSize(TargetSize(640, 360))
|
|
60
|
+
source.emitOnce()
|
|
61
|
+
|
|
62
|
+
assertThat(frames).containsExactly(
|
|
63
|
+
1280 to 720,
|
|
64
|
+
1280 to 720,
|
|
65
|
+
1280 to 720,
|
|
66
|
+
1280 to 720,
|
|
67
|
+
640 to 360,
|
|
68
|
+
)
|
|
69
|
+
assertThat(stats.sinkCount()).isEqualTo(5)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
@Test
|
|
73
|
+
fun withheldTicksTouchNoCounters() {
|
|
74
|
+
val stats = PipelineStats()
|
|
75
|
+
val source = source(stats, AtomicBoolean(false)) { _ -> }
|
|
76
|
+
source.becomeLive()
|
|
77
|
+
source.setTargetSize(TargetSize(1280, 720))
|
|
78
|
+
repeat(5) { source.emitOnce() }
|
|
79
|
+
assertThat(stats.sinkCount()).isEqualTo(0)
|
|
80
|
+
assertThat(stats.dropCount()).isEqualTo(0)
|
|
81
|
+
assertThat(stats.subCount()).isEqualTo(0)
|
|
82
|
+
assertThat(stats.pack.p95()).isEqualTo("na")
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
@Test
|
|
86
|
+
fun readinessFlapProducesOnePauseAndOneResume() {
|
|
87
|
+
val ready = AtomicBoolean(true)
|
|
88
|
+
val source = source(PipelineStats(), ready) { _ -> }
|
|
89
|
+
source.becomeLive()
|
|
90
|
+
source.setTargetSize(TargetSize(1280, 720))
|
|
91
|
+
source.emitOnce()
|
|
92
|
+
ready.set(false)
|
|
93
|
+
source.emitOnce()
|
|
94
|
+
ready.set(true)
|
|
95
|
+
source.emitOnce()
|
|
96
|
+
|
|
97
|
+
val pauses = source.events.filter { it.contains("paused") }
|
|
98
|
+
val resumes = source.events.filter { it.contains("resumed") }
|
|
99
|
+
assertThat(pauses).hasSize(1)
|
|
100
|
+
assertThat(resumes).hasSize(1)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
@Test
|
|
104
|
+
fun stopEmitsNothingAndRestartResetsIndex() {
|
|
105
|
+
val indexes = ArrayList<Int>()
|
|
106
|
+
val ready = AtomicBoolean(true)
|
|
107
|
+
val source = source(PipelineStats(), ready) { planes ->
|
|
108
|
+
indexes.add(yCorner(planes))
|
|
109
|
+
}
|
|
110
|
+
source.becomeLive()
|
|
111
|
+
source.setTargetSize(TargetSize(64, 64))
|
|
112
|
+
source.emitOnce()
|
|
113
|
+
source.emitOnce()
|
|
114
|
+
source.stop()
|
|
115
|
+
assertThat(source.state).isEqualTo(SourceState.IDLE)
|
|
116
|
+
assertThat(source.emitOnce()).isFalse()
|
|
117
|
+
|
|
118
|
+
source.becomeLive()
|
|
119
|
+
source.setTargetSize(TargetSize(64, 64))
|
|
120
|
+
source.emitOnce()
|
|
121
|
+
assertThat(indexes).hasSize(3)
|
|
122
|
+
assertThat(indexes[0]).isEqualTo(indexes[2])
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
@Test
|
|
126
|
+
fun restartLeavesSourceLive() {
|
|
127
|
+
val source = source(PipelineStats(), AtomicBoolean(false)) { _ -> }
|
|
128
|
+
source.start(SourceConfig("ignored", SourceKind.DIRECT))
|
|
129
|
+
source.restart(SourceConfig("ignored-2", SourceKind.DIRECT))
|
|
130
|
+
assertThat(source.state).isEqualTo(SourceState.LIVE)
|
|
131
|
+
source.stop()
|
|
132
|
+
assertThat(source.state).isEqualTo(SourceState.IDLE)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
@Test
|
|
136
|
+
fun pcmListenerIsNeverInvoked() {
|
|
137
|
+
val source = SyntheticI420Source(
|
|
138
|
+
video = { _ -> },
|
|
139
|
+
stats = PipelineStats(),
|
|
140
|
+
isReady = { true },
|
|
141
|
+
)
|
|
142
|
+
source.setPcmDeliveryEnabled(true)
|
|
143
|
+
assertThat(source.pcmDeliveryEnabled()).isTrue()
|
|
144
|
+
source.setPcmDeliveryEnabled(false)
|
|
145
|
+
assertThat(source.pcmDeliveryEnabled()).isFalse()
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private fun source(
|
|
149
|
+
stats: PipelineStats,
|
|
150
|
+
ready: AtomicBoolean,
|
|
151
|
+
video: VideoFrameListener,
|
|
152
|
+
): SyntheticI420Source = SyntheticI420Source(
|
|
153
|
+
video = video,
|
|
154
|
+
stats = stats,
|
|
155
|
+
isReady = { ready.get() },
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
@Test
|
|
159
|
+
fun emitsTightStridePlanesAliasingThePackedBuffer() {
|
|
160
|
+
val seen = ArrayList<I420Planes>()
|
|
161
|
+
val source = source(PipelineStats(), AtomicBoolean(true)) { seen.add(it) }
|
|
162
|
+
source.becomeLive()
|
|
163
|
+
source.setTargetSize(TargetSize(64, 64))
|
|
164
|
+
assertThat(source.emitOnce()).isTrue()
|
|
165
|
+
val planes = seen.single()
|
|
166
|
+
assertThat(planes.isTight()).isTrue()
|
|
167
|
+
assertThat(planes.retain == null).isTrue()
|
|
168
|
+
assertThat(planes.release == null).isTrue()
|
|
169
|
+
assertThat(I420Planes.remaining(planes.y)).isEqualTo(64 * 64)
|
|
170
|
+
assertThat(I420Planes.remaining(planes.u)).isEqualTo(32 * 32)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
private fun yCorner(planes: I420Planes): Int {
|
|
174
|
+
return planes.y.duplicate().get(0).toInt() and 0xFF
|
|
175
|
+
}
|
|
176
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
package com.mentra.acsmeeting.source
|
|
2
|
+
|
|
3
|
+
import org.assertj.core.api.Assertions.assertThat
|
|
4
|
+
import org.junit.Test
|
|
5
|
+
|
|
6
|
+
class TrackRegistryTest {
|
|
7
|
+
@Test
|
|
8
|
+
fun threeClaimsOfOneIdYieldTrueFalseFalse() {
|
|
9
|
+
val registry = TrackRegistry()
|
|
10
|
+
assertThat(registry.claim("track-a")).isTrue()
|
|
11
|
+
assertThat(registry.claim("track-a")).isFalse()
|
|
12
|
+
assertThat(registry.claim("track-a")).isFalse()
|
|
13
|
+
assertThat(registry.size()).isEqualTo(1)
|
|
14
|
+
assertThat(registry.skipped()).isEqualTo(2)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
@Test
|
|
18
|
+
fun distinctIdsEachClaimOnce() {
|
|
19
|
+
val registry = TrackRegistry()
|
|
20
|
+
assertThat(registry.claim("video")).isTrue()
|
|
21
|
+
assertThat(registry.claim("audio")).isTrue()
|
|
22
|
+
assertThat(registry.size()).isEqualTo(2)
|
|
23
|
+
assertThat(registry.skipped()).isEqualTo(0)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
@Test
|
|
27
|
+
fun resetMakesTheSameIdClaimableAgain() {
|
|
28
|
+
val registry = TrackRegistry()
|
|
29
|
+
assertThat(registry.claim("track-a")).isTrue()
|
|
30
|
+
registry.reset()
|
|
31
|
+
assertThat(registry.size()).isEqualTo(0)
|
|
32
|
+
assertThat(registry.skipped()).isEqualTo(0)
|
|
33
|
+
assertThat(registry.claim("track-a")).isTrue()
|
|
34
|
+
assertThat(registry.size()).isEqualTo(1)
|
|
35
|
+
}
|
|
36
|
+
}
|