app/src/androidTest/kotlin/space/subread/overlay/OverlayTest.kt (17043 bytes)
1 package space.subread.overlay 2 3 import android.media.session.MediaController 4 import android.media.session.MediaSession 5 import android.media.session.PlaybackState 6 import android.os.Handler 7 import android.os.Looper 8 import android.os.SystemClock 9 import android.view.ViewGroup 10 import androidx.test.core.app.ActivityScenario 11 import androidx.test.ext.junit.runners.AndroidJUnit4 12 import androidx.test.platform.app.InstrumentationRegistry 13 import org.junit.Assert.assertEquals 14 import org.junit.Assert.assertNull 15 import org.junit.Assert.assertTrue 16 import org.junit.Test 17 import org.junit.runner.RunWith 18 import space.subread.overlay.core.Cue 19 import space.subread.overlay.core.CueIndex 20 import java.util.concurrent.CopyOnWriteArrayList 21 22 /** The word cutter of the device. It is ICU of Android, so the test runs on the device. */ 23 @RunWith(AndroidJUnit4::class) 24 class WordsTest { 25 26 private fun word(text: String, offset: Int) = Words.at(text, offset)?.let { text.substring(it.first, it.last + 1) } 27 28 @Test 29 fun aWordOfALanguageWithSpaces() { 30 val text = "Он вышел из дома, and never came back." 31 assertEquals("вышел", word(text, text.indexOf("шел"))) 32 assertEquals("never", word(text, text.indexOf("never"))) 33 assertNull("a space", word(text, 2)) 34 assertNull("a comma", word(text, text.indexOf(','))) 35 assertNull(word(text, text.length)) 36 assertNull(word("", 0)) 37 } 38 39 @Test 40 fun aWordWithAccentsOrInvertedMarks() { 41 val pt = "— Não me parece bonito — disse ela, à porta." 42 assertEquals("Não", word(pt, pt.indexOf("Não"))) 43 assertEquals("à", word(pt, pt.indexOf("à"))) 44 assertNull("a dash", word(pt, 0)) 45 val es = "¿Qué es esto? ¡Ñandú, señor!" 46 assertEquals("Qué", word(es, es.indexOf("Qué"))) 47 assertEquals("Ñandú", word(es, es.indexOf("Ñandú"))) 48 assertNull("the inverted mark", word(es, 0)) 49 val ru = "«Ёлка, — сказал он, — и её огни»." 50 assertEquals("Ёлка", word(ru, ru.indexOf("Ёлка"))) 51 assertEquals("её", word(ru, ru.indexOf("её"))) 52 } 53 54 @Test 55 fun aWordOfJapaneseThatHasNoSpaces() { 56 val text = "女のいない男たちは、東京で暮らしている。" 57 // The dictionary of ICU decides the cut. The test asks for what each good cut has: 58 // a word is shorter than the sentence, stops at the comma, and has the character under the finger. 59 for (at in listOf(0, text.indexOf("男"), text.indexOf("東京"), text.indexOf("暮"))) { 60 val range = Words.at(text, at)!! 61 assertTrue("$range has $at", at in range) 62 assertTrue("$range is a word, not the sentence", range.last - range.first < 6) 63 assertTrue("no comma in a word", '、' !in text.substring(range.first, range.last + 1)) 64 } 65 assertEquals("東京", word(text, text.indexOf("東京"))) 66 assertNull(word(text, text.indexOf('、'))) 67 } 68 69 @Test 70 fun aDragSelectsFromTheFirstWordToTheWordUnderTheFinger() { 71 val text = "one two three four" 72 val anchor = Words.at(text, text.indexOf("two"))!! 73 assertEquals("two three", Words.between(text, anchor, text.indexOf("three")).let { text.substring(it.first, it.last + 1) }) 74 assertEquals("one two", Words.between(text, anchor, 0).let { text.substring(it.first, it.last + 1) }) 75 assertEquals("a drag over a space keeps the selection", anchor, Words.between(text, anchor, 3)) 76 } 77 } 78 79 /** The panel, in the window of an activity: the test needs no permission that way. */ 80 @RunWith(AndroidJUnit4::class) 81 class OverlayViewTest { 82 83 private val lookedUp = CopyOnWriteArrayList<String>() 84 private var touches = 0 85 private val shared = CopyOnWriteArrayList<String>() 86 private val events = object : OverlayView.Events { 87 override fun onDrag(dx: Float, dy: Float) = Unit 88 override fun onDragEnd() = Unit 89 override fun onClose() = Unit 90 override fun onTouchWord() { touches++ } 91 override fun onLookUp(word: String) { lookedUp += word } 92 override fun onShare(word: String) { shared += word } 93 override fun onTogglePlay() = Unit 94 override fun onShiftLines(steps: Int) = Unit 95 override fun onNudge(ms: Long) = Unit 96 } 97 98 /** The x of the middle of the character at [offset], in the panel. */ 99 private fun OverlayView.middleOf(offset: Int): Float { 100 val layout = text.layout 101 return text.totalPaddingLeft + (layout.getPrimaryHorizontal(offset) + layout.getPrimaryHorizontal(offset + 1)) / 2 102 } 103 104 /** A y just over the baseline of the row of the character at [offset], in the panel. */ 105 private fun OverlayView.rowOf(offset: Int): Float = 106 text.totalPaddingTop + text.layout.getLineBaseline(text.layout.getLineForOffset(offset)) - 5f 107 108 /** Gives a touch event at [x], [y] of the text view to the text view. */ 109 private fun OverlayView.touch(action: Int, x: Float, y: Float) { 110 val now = SystemClock.uptimeMillis() 111 val event = android.view.MotionEvent.obtain(now, now, action, x, y, 0) 112 text.dispatchTouchEvent(event) 113 event.recycle() 114 } 115 116 @Test 117 fun aTouchPausesAtOnceAndTheLiftLooksUpTheSelection() { 118 ActivityScenario.launch(MainActivity::class.java).use { scenario -> 119 lateinit var panel: OverlayView 120 scenario.onActivity { 121 panel = OverlayView(it, events) 122 panel.setTextSize(30f) 123 it.addContentView(panel, ViewGroup.LayoutParams(-1, -2)) 124 panel.showLine("reading along is easy") 125 } 126 InstrumentationRegistry.getInstrumentation().waitForIdleSync() 127 scenario.onActivity { 128 val start = panel.line.indexOf("along") 129 val last = panel.line.lastIndex 130 panel.touch(android.view.MotionEvent.ACTION_DOWN, panel.middleOf(start + 2), panel.rowOf(start + 2)) 131 assertEquals("the finger on a word pauses the player", 1, touches) 132 assertEquals("no lookup before the finger lifts", emptyList<String>(), lookedUp) 133 panel.touch(android.view.MotionEvent.ACTION_MOVE, panel.middleOf(last), panel.rowOf(last)) 134 panel.touch(android.view.MotionEvent.ACTION_UP, panel.middleOf(last), panel.rowOf(last)) 135 assertEquals(listOf("along is easy"), lookedUp) 136 137 val found = arrayListOf<android.view.View>() 138 panel.findViewsWithText(found, "Look up", android.view.View.FIND_VIEWS_WITH_TEXT) 139 assertEquals("no \"Look up\" button", 0, found.size) 140 141 // A tap beside the words neither pauses nor looks up. 142 val right = panel.text.width - 1f 143 panel.touch(android.view.MotionEvent.ACTION_DOWN, right, panel.rowOf(last)) 144 panel.touch(android.view.MotionEvent.ACTION_UP, right, panel.rowOf(last)) 145 assertNull(panel.selection) 146 assertEquals(1, touches) 147 assertEquals(listOf("along is easy"), lookedUp) 148 } 149 } 150 } 151 152 @Test 153 fun aTapSelectsTheWordUnderTheFingerAndADragSelectsMore() { 154 ActivityScenario.launch(MainActivity::class.java).use { scenario -> 155 lateinit var panel: OverlayView 156 scenario.onActivity { 157 panel = OverlayView(it, events) 158 panel.setTextSize(30f) 159 it.addContentView(panel, ViewGroup.LayoutParams(-1, -2)) 160 panel.showLine("reading along is easy") 161 } 162 InstrumentationRegistry.getInstrumentation().waitForIdleSync() 163 scenario.onActivity { 164 val start = panel.line.indexOf("along") 165 // The middle of the third letter of "along". 166 panel.selectAt(panel.middleOf(start + 2), panel.rowOf(start + 2)) 167 assertEquals(start..start + 4, panel.selection) 168 169 // A drag to the last word selects the three words. 170 val last = panel.line.lastIndex 171 panel.selectAt(panel.middleOf(last), panel.rowOf(last), extend = true) 172 assertEquals(start..last, panel.selection) 173 174 val found = arrayListOf<android.view.View>() 175 panel.findViewsWithText(found, "Share", android.view.View.FIND_VIEWS_WITH_TEXT) 176 found.single().performClick() 177 assertEquals(listOf("along is easy"), shared) 178 179 // The next line has no selection from the line before. 180 panel.showLine("the next line") 181 assertNull(panel.selection) 182 } 183 } 184 } 185 186 @Test 187 fun theLinesAroundAreOnTheirOwnRowsAndAWordOfThemCanBeSelected() { 188 ActivityScenario.launch(MainActivity::class.java).use { scenario -> 189 lateinit var panel: OverlayView 190 scenario.onActivity { 191 panel = OverlayView(it, events) 192 panel.setTextSize(30f) 193 it.addContentView(panel, ViewGroup.LayoutParams(-1, -2)) 194 panel.showLine("the line of now", "the line before", "the line after") 195 } 196 InstrumentationRegistry.getInstrumentation().waitForIdleSync() 197 scenario.onActivity { 198 assertEquals("the line before\nthe line of now\nthe line after", panel.line) 199 val layout = panel.text.layout 200 val now = panel.line.indexOf("the line of now") 201 val start = panel.line.indexOf("after") 202 assertTrue("each line on its own row", layout.getLineForOffset(0) < layout.getLineForOffset(now)) 203 assertTrue("each line on its own row", layout.getLineForOffset(now) < layout.getLineForOffset(start)) 204 panel.selectAt(panel.middleOf(start + 1), panel.rowOf(start + 1)) 205 assertEquals(start..start + 4, panel.selection) 206 207 // The same lines again change nothing; one line alone is one row again. 208 panel.showLine("the line of now", "the line before", "the line after") 209 assertEquals(start..start + 4, panel.selection) 210 panel.showLine("the line of now") 211 assertEquals("the line of now", panel.line) 212 assertNull(panel.selection) 213 } 214 } 215 } 216 217 @Test 218 fun theTransparencyIsTheAlphaOfTheBackground() { 219 ActivityScenario.launch(MainActivity::class.java).use { scenario -> 220 scenario.onActivity { 221 val panel = OverlayView(it, events) 222 panel.setTransparency(40) 223 assertEquals(153, panel.background.alpha) 224 panel.setTransparency(0) 225 assertEquals(255, panel.background.alpha) 226 } 227 } 228 } 229 } 230 231 /** A media session of the test plays the part of Voice, VLC or YouTube. */ 232 @RunWith(AndroidJUnit4::class) 233 class FollowerTest { 234 235 private val context = InstrumentationRegistry.getInstrumentation().targetContext 236 private val main = Handler(Looper.getMainLooper()) 237 private val lines = CopyOnWriteArrayList<String?>() 238 private val index = CueIndex(listOf(Cue(1_000, 2_000, "one"), Cue(60_000, 61_000, "two"), Cue(60_400, 61_000, "three"))) 239 240 private fun state(state: Int, position: Long, speed: Float = 1f) = PlaybackState.Builder() 241 .setState(state, position, speed, SystemClock.elapsedRealtime()).build() 242 243 private fun onMain(block: () -> Unit) = InstrumentationRegistry.getInstrumentation().runOnMainSync(block) 244 245 private fun waitFor(line: String?) { 246 val until = SystemClock.elapsedRealtime() + 5_000 247 while (lines.lastOrNull() != line && SystemClock.elapsedRealtime() < until) Thread.sleep(20) 248 assertEquals(lines.toString(), line, lines.lastOrNull()) 249 } 250 251 @Test 252 fun theLineFollowsThePlayerThroughPauseSeekSpeedAndOffset() { 253 val session = MediaSession(context, "test player") 254 lateinit var follower: Follower 255 try { 256 session.setPlaybackState(state(PlaybackState.STATE_PAUSED, 1_500)) 257 session.isActive = true 258 onMain { 259 follower = Follower(main) { at, _, _ -> lines += follower.index.cues.getOrNull(at)?.text } 260 follower.index = index 261 follower.follow(listOf(MediaController(context, session.sessionToken))) 262 } 263 waitFor("one") 264 265 // A seek. The player is paused: the line changes at once and then stays. 266 session.setPlaybackState(state(PlaybackState.STATE_PAUSED, 500)) 267 waitFor(null) 268 269 // Play at double speed from 59.6 s: "two" is 0.2 s away, "three" 0.2 s after it. 270 val started = SystemClock.elapsedRealtime() 271 session.setPlaybackState(state(PlaybackState.STATE_PLAYING, 59_600, speed = 2f)) 272 waitFor("one") 273 waitFor("two") 274 waitFor("three") 275 val took = SystemClock.elapsedRealtime() - started 276 assertTrue("the follower waited $took ms; at double speed 0.8 s of media is 0.4 s", took in 350..1_500) 277 278 // The subtitles are 60 s late for this media: the user shifts them. 279 session.setPlaybackState(state(PlaybackState.STATE_PAUSED, 100)) 280 waitFor(null) 281 onMain { follower.offsetMs = 60_000 } 282 waitFor("two") 283 284 // "line ▶" makes the next line the line of now. 285 onMain { follower.shiftLines(1) } 286 waitFor("three") 287 onMain { assertEquals(60_400L - 100L, follower.offsetMs) } 288 } finally { 289 onMain { follower.stop() } 290 session.release() 291 } 292 } 293 294 @Test 295 fun aPlayerWithoutAPositionKeepsItsLineWhenItPauses() { 296 val session = MediaSession(context, "player without position") 297 lateinit var follower: Follower 298 try { 299 session.setPlaybackState(state(PlaybackState.STATE_PLAYING, PlaybackState.PLAYBACK_POSITION_UNKNOWN)) 300 session.isActive = true 301 onMain { 302 follower = Follower(main) { at, _, _ -> lines += follower.index.cues.getOrNull(at)?.text } 303 follower.index = CueIndex(listOf(Cue(0, 300, "first"), Cue(400, 900, "second"))) 304 follower.follow(listOf(MediaController(context, session.sessionToken))) 305 } 306 // Android counts from zero for a player that plays and gives no position. 307 waitFor("second") 308 // In a pause there is no position at all. The first version went back to "before the first line". 309 session.setPlaybackState(state(PlaybackState.STATE_PAUSED, PlaybackState.PLAYBACK_POSITION_UNKNOWN)) 310 Thread.sleep(500) 311 assertEquals(lines.toString(), "second", lines.last()) 312 } finally { 313 onMain { follower.stop() } 314 session.release() 315 } 316 } 317 318 @Test 319 fun noPlayerIsSaidSo() { 320 val players = CopyOnWriteArrayList<Boolean>() 321 onMain { 322 val follower = Follower(main) { _, hasPlayer, _ -> players += hasPlayer } 323 follower.follow(emptyList()) 324 } 325 assertEquals(false, players.last()) 326 } 327 328 @Test 329 fun aLookupPausesThePlayerAndTheButtonStartsItAgain() { 330 val session = MediaSession(context, "test player") 331 val playing = CopyOnWriteArrayList<Boolean>() 332 var plays = 0 333 var pauses = 0 334 session.setCallback(object : MediaSession.Callback() { 335 override fun onPlay() { plays++ } 336 override fun onPause() { pauses++ } 337 }, main) 338 lateinit var follower: Follower 339 try { 340 session.setPlaybackState(state(PlaybackState.STATE_PLAYING, 1_500)) 341 session.isActive = true 342 onMain { 343 follower = Follower(main) { _, _, isPlaying -> playing += isPlaying } 344 follower.index = index 345 follower.follow(listOf(MediaController(context, session.sessionToken))) 346 } 347 val until = SystemClock.elapsedRealtime() + 5_000 348 while (playing.lastOrNull() != true && SystemClock.elapsedRealtime() < until) Thread.sleep(20) 349 assertEquals(true, playing.last()) 350 351 onMain { assertTrue("a player that plays is paused", follower.pause()) } 352 session.setPlaybackState(state(PlaybackState.STATE_PAUSED, 1_500)) 353 while (playing.lastOrNull() != false && SystemClock.elapsedRealtime() < until) Thread.sleep(20) 354 assertEquals(false, playing.last()) 355 onMain { assertEquals("a paused player is not paused again", false, follower.pause()) } 356 onMain { follower.play() } 357 while ((plays < 1 || pauses < 1) && SystemClock.elapsedRealtime() < until) Thread.sleep(20) 358 assertEquals(1, pauses) 359 assertEquals(1, plays) 360 } finally { 361 onMain { follower.stop() } 362 session.release() 363 } 364 } 365 }