app/src/androidTest/kotlin/space/subread/overlay/OverlayTest.kt (14185 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 aWordOfJapaneseThatHasNoSpaces() { 41 val text = "女のいない男たちは、東京で暮らしている。" 42 // The dictionary of ICU decides the cut. The test asks for what each good cut has: 43 // a word is shorter than the sentence, stops at the comma, and has the character under the finger. 44 for (at in listOf(0, text.indexOf("男"), text.indexOf("東京"), text.indexOf("暮"))) { 45 val range = Words.at(text, at)!! 46 assertTrue("$range has $at", at in range) 47 assertTrue("$range is a word, not the sentence", range.last - range.first < 6) 48 assertTrue("no comma in a word", '、' !in text.substring(range.first, range.last + 1)) 49 } 50 assertEquals("東京", word(text, text.indexOf("東京"))) 51 assertNull(word(text, text.indexOf('、'))) 52 } 53 54 @Test 55 fun aDragSelectsFromTheFirstWordToTheWordUnderTheFinger() { 56 val text = "one two three four" 57 val anchor = Words.at(text, text.indexOf("two"))!! 58 assertEquals("two three", Words.between(text, anchor, text.indexOf("three")).let { text.substring(it.first, it.last + 1) }) 59 assertEquals("one two", Words.between(text, anchor, 0).let { text.substring(it.first, it.last + 1) }) 60 assertEquals("a drag over a space keeps the selection", anchor, Words.between(text, anchor, 3)) 61 } 62 } 63 64 /** The panel, in the window of an activity: the test needs no permission that way. */ 65 @RunWith(AndroidJUnit4::class) 66 class OverlayViewTest { 67 68 private val lookedUp = CopyOnWriteArrayList<String>() 69 private val shared = CopyOnWriteArrayList<String>() 70 private val events = object : OverlayView.Events { 71 override fun onDrag(dx: Float, dy: Float) = Unit 72 override fun onDragEnd() = Unit 73 override fun onClose() = Unit 74 override fun onLookUp(word: String) { lookedUp += word } 75 override fun onShare(word: String) { shared += word } 76 override fun onTogglePlay() = Unit 77 override fun onShiftLines(steps: Int) = Unit 78 override fun onNudge(ms: Long) = Unit 79 } 80 81 /** The x of the middle of the character at [offset], in the panel. */ 82 private fun OverlayView.middleOf(offset: Int): Float { 83 val layout = text.layout 84 return text.totalPaddingLeft + (layout.getPrimaryHorizontal(offset) + layout.getPrimaryHorizontal(offset + 1)) / 2 85 } 86 87 /** A y just over the baseline of the row of the character at [offset], in the panel. */ 88 private fun OverlayView.rowOf(offset: Int): Float = 89 text.totalPaddingTop + text.layout.getLineBaseline(text.layout.getLineForOffset(offset)) - 5f 90 91 @Test 92 fun aTapSelectsTheWordUnderTheFingerAndLookUpSendsIt() { 93 ActivityScenario.launch(MainActivity::class.java).use { scenario -> 94 lateinit var panel: OverlayView 95 scenario.onActivity { 96 panel = OverlayView(it, events) 97 panel.setTextSize(30f) 98 it.addContentView(panel, ViewGroup.LayoutParams(-1, -2)) 99 panel.showLine("reading along is easy") 100 } 101 InstrumentationRegistry.getInstrumentation().waitForIdleSync() 102 scenario.onActivity { 103 val start = panel.line.indexOf("along") 104 // The middle of the third letter of "along". 105 panel.selectAt(panel.middleOf(start + 2), panel.rowOf(start + 2)) 106 assertEquals(start..start + 4, panel.selection) 107 108 // A drag to the last word selects the three words. 109 val last = panel.line.lastIndex 110 panel.selectAt(panel.middleOf(last), panel.rowOf(last), extend = true) 111 assertEquals(start..last, panel.selection) 112 113 val found = arrayListOf<android.view.View>() 114 panel.findViewsWithText(found, "Look up", android.view.View.FIND_VIEWS_WITH_TEXT) 115 found.single().performClick() 116 assertEquals(listOf("along is easy"), lookedUp) 117 found.clear() 118 panel.findViewsWithText(found, "Share", android.view.View.FIND_VIEWS_WITH_TEXT) 119 found.single().performClick() 120 assertEquals(listOf("along is easy"), shared) 121 122 // The next line has no selection from the line before. 123 panel.showLine("the next line") 124 assertNull(panel.selection) 125 } 126 } 127 } 128 129 @Test 130 fun theLinesAroundAreOnTheirOwnRowsAndAWordOfThemCanBeSelected() { 131 ActivityScenario.launch(MainActivity::class.java).use { scenario -> 132 lateinit var panel: OverlayView 133 scenario.onActivity { 134 panel = OverlayView(it, events) 135 panel.setTextSize(30f) 136 it.addContentView(panel, ViewGroup.LayoutParams(-1, -2)) 137 panel.showLine("the line of now", "the line before", "the line after") 138 } 139 InstrumentationRegistry.getInstrumentation().waitForIdleSync() 140 scenario.onActivity { 141 assertEquals("the line before\nthe line of now\nthe line after", panel.line) 142 val layout = panel.text.layout 143 val now = panel.line.indexOf("the line of now") 144 val start = panel.line.indexOf("after") 145 assertTrue("each line on its own row", layout.getLineForOffset(0) < layout.getLineForOffset(now)) 146 assertTrue("each line on its own row", layout.getLineForOffset(now) < layout.getLineForOffset(start)) 147 panel.selectAt(panel.middleOf(start + 1), panel.rowOf(start + 1)) 148 assertEquals(start..start + 4, panel.selection) 149 150 // The same lines again change nothing; one line alone is one row again. 151 panel.showLine("the line of now", "the line before", "the line after") 152 assertEquals(start..start + 4, panel.selection) 153 panel.showLine("the line of now") 154 assertEquals("the line of now", panel.line) 155 assertNull(panel.selection) 156 } 157 } 158 } 159 160 @Test 161 fun theTransparencyIsTheAlphaOfTheBackground() { 162 ActivityScenario.launch(MainActivity::class.java).use { scenario -> 163 scenario.onActivity { 164 val panel = OverlayView(it, events) 165 panel.setTransparency(40) 166 assertEquals(153, panel.background.alpha) 167 panel.setTransparency(0) 168 assertEquals(255, panel.background.alpha) 169 } 170 } 171 } 172 } 173 174 /** A media session of the test plays the part of Voice, VLC or YouTube. */ 175 @RunWith(AndroidJUnit4::class) 176 class FollowerTest { 177 178 private val context = InstrumentationRegistry.getInstrumentation().targetContext 179 private val main = Handler(Looper.getMainLooper()) 180 private val lines = CopyOnWriteArrayList<String?>() 181 private val index = CueIndex(listOf(Cue(1_000, 2_000, "one"), Cue(60_000, 61_000, "two"), Cue(60_400, 61_000, "three"))) 182 183 private fun state(state: Int, position: Long, speed: Float = 1f) = PlaybackState.Builder() 184 .setState(state, position, speed, SystemClock.elapsedRealtime()).build() 185 186 private fun onMain(block: () -> Unit) = InstrumentationRegistry.getInstrumentation().runOnMainSync(block) 187 188 private fun waitFor(line: String?) { 189 val until = SystemClock.elapsedRealtime() + 5_000 190 while (lines.lastOrNull() != line && SystemClock.elapsedRealtime() < until) Thread.sleep(20) 191 assertEquals(lines.toString(), line, lines.lastOrNull()) 192 } 193 194 @Test 195 fun theLineFollowsThePlayerThroughPauseSeekSpeedAndOffset() { 196 val session = MediaSession(context, "test player") 197 lateinit var follower: Follower 198 try { 199 session.setPlaybackState(state(PlaybackState.STATE_PAUSED, 1_500)) 200 session.isActive = true 201 onMain { 202 follower = Follower(main) { at, _, _ -> lines += follower.index.cues.getOrNull(at)?.text } 203 follower.index = index 204 follower.follow(listOf(MediaController(context, session.sessionToken))) 205 } 206 waitFor("one") 207 208 // A seek. The player is paused: the line changes at once and then stays. 209 session.setPlaybackState(state(PlaybackState.STATE_PAUSED, 500)) 210 waitFor(null) 211 212 // Play at double speed from 59.6 s: "two" is 0.2 s away, "three" 0.2 s after it. 213 val started = SystemClock.elapsedRealtime() 214 session.setPlaybackState(state(PlaybackState.STATE_PLAYING, 59_600, speed = 2f)) 215 waitFor("one") 216 waitFor("two") 217 waitFor("three") 218 val took = SystemClock.elapsedRealtime() - started 219 assertTrue("the follower waited $took ms; at double speed 0.8 s of media is 0.4 s", took in 350..1_500) 220 221 // The subtitles are 60 s late for this media: the user shifts them. 222 session.setPlaybackState(state(PlaybackState.STATE_PAUSED, 100)) 223 waitFor(null) 224 onMain { follower.offsetMs = 60_000 } 225 waitFor("two") 226 227 // "line ▶" makes the next line the line of now. 228 onMain { follower.shiftLines(1) } 229 waitFor("three") 230 onMain { assertEquals(60_400L - 100L, follower.offsetMs) } 231 } finally { 232 onMain { follower.stop() } 233 session.release() 234 } 235 } 236 237 @Test 238 fun aPlayerWithoutAPositionKeepsItsLineWhenItPauses() { 239 val session = MediaSession(context, "player without position") 240 lateinit var follower: Follower 241 try { 242 session.setPlaybackState(state(PlaybackState.STATE_PLAYING, PlaybackState.PLAYBACK_POSITION_UNKNOWN)) 243 session.isActive = true 244 onMain { 245 follower = Follower(main) { at, _, _ -> lines += follower.index.cues.getOrNull(at)?.text } 246 follower.index = CueIndex(listOf(Cue(0, 300, "first"), Cue(400, 900, "second"))) 247 follower.follow(listOf(MediaController(context, session.sessionToken))) 248 } 249 // Android counts from zero for a player that plays and gives no position. 250 waitFor("second") 251 // In a pause there is no position at all. The first version went back to "before the first line". 252 session.setPlaybackState(state(PlaybackState.STATE_PAUSED, PlaybackState.PLAYBACK_POSITION_UNKNOWN)) 253 Thread.sleep(500) 254 assertEquals(lines.toString(), "second", lines.last()) 255 } finally { 256 onMain { follower.stop() } 257 session.release() 258 } 259 } 260 261 @Test 262 fun noPlayerIsSaidSo() { 263 val players = CopyOnWriteArrayList<Boolean>() 264 onMain { 265 val follower = Follower(main) { _, hasPlayer, _ -> players += hasPlayer } 266 follower.follow(emptyList()) 267 } 268 assertEquals(false, players.last()) 269 } 270 271 @Test 272 fun aLookupPausesThePlayerAndTheButtonStartsItAgain() { 273 val session = MediaSession(context, "test player") 274 val playing = CopyOnWriteArrayList<Boolean>() 275 var plays = 0 276 var pauses = 0 277 session.setCallback(object : MediaSession.Callback() { 278 override fun onPlay() { plays++ } 279 override fun onPause() { pauses++ } 280 }, main) 281 lateinit var follower: Follower 282 try { 283 session.setPlaybackState(state(PlaybackState.STATE_PLAYING, 1_500)) 284 session.isActive = true 285 onMain { 286 follower = Follower(main) { _, _, isPlaying -> playing += isPlaying } 287 follower.index = index 288 follower.follow(listOf(MediaController(context, session.sessionToken))) 289 } 290 val until = SystemClock.elapsedRealtime() + 5_000 291 while (playing.lastOrNull() != true && SystemClock.elapsedRealtime() < until) Thread.sleep(20) 292 assertEquals(true, playing.last()) 293 294 onMain { assertTrue("a player that plays is paused", follower.pause()) } 295 session.setPlaybackState(state(PlaybackState.STATE_PAUSED, 1_500)) 296 while (playing.lastOrNull() != false && SystemClock.elapsedRealtime() < until) Thread.sleep(20) 297 assertEquals(false, playing.last()) 298 onMain { assertEquals("a paused player is not paused again", false, follower.pause()) } 299 onMain { follower.play() } 300 while ((plays < 1 || pauses < 1) && SystemClock.elapsedRealtime() < until) Thread.sleep(20) 301 assertEquals(1, pauses) 302 assertEquals(1, plays) 303 } finally { 304 onMain { follower.stop() } 305 session.release() 306 } 307 } 308 }