app/src/test/kotlin/com/honjimaku/subrep/CloudTest.kt (7560 bytes)
1 package com.honjimaku.subrep 2 3 import com.sun.net.httpserver.HttpExchange 4 import com.sun.net.httpserver.HttpServer 5 import io.kotest.property.Arb 6 import io.kotest.property.arbitrary.float 7 import io.kotest.property.arbitrary.list 8 import io.kotest.property.checkAll 9 import kotlinx.coroutines.runBlocking 10 import okhttp3.OkHttpClient 11 import org.junit.After 12 import org.junit.Assert.assertArrayEquals 13 import org.junit.Assert.assertEquals 14 import org.junit.Assert.assertNull 15 import org.junit.Assert.assertTrue 16 import org.junit.Assert.fail 17 import org.junit.Before 18 import org.junit.Test 19 import java.net.InetSocketAddress 20 import java.util.concurrent.CopyOnWriteArrayList 21 import kotlin.math.abs 22 import kotlin.random.Random 23 24 /** The cloud client against a local server that answers as subread.space does. */ 25 class CloudTest { 26 27 private class Seen(val method: String, val query: String?, val cookie: String?, val body: ByteArray) 28 29 private lateinit var server: HttpServer 30 private val seen = CopyOnWriteArrayList<Seen>() 31 private var answer: (HttpExchange) -> Pair<Int, String> = { 200 to "{}" } 32 private var device = "" 33 34 private fun cloud() = Cloud(OkHttpClient(), { device }, { device = it }, "http://127.0.0.1:${server.address.port}") 35 36 @Before 37 fun start() { 38 server = HttpServer.create(InetSocketAddress("127.0.0.1", 0), 0) 39 server.createContext("/") { exchange -> 40 val body = exchange.requestBody.readBytes() 41 seen += Seen(exchange.requestMethod, exchange.requestURI.rawQuery, exchange.requestHeaders.getFirst("Cookie"), body) 42 if (exchange.requestHeaders.getFirst("Cookie") == null) { 43 exchange.responseHeaders.add("Set-Cookie", "subplz_device=dev123; HttpOnly; Path=/; SameSite=lax") 44 } 45 val (code, text) = answer(exchange) 46 val bytes = text.toByteArray() 47 exchange.sendResponseHeaders(code, bytes.size.toLong()) 48 exchange.responseBody.use { it.write(bytes) } 49 } 50 server.start() 51 } 52 53 @After 54 fun stop() = server.stop(0) 55 56 @Test 57 fun theFirstAnswerMakesTheAccountAndEachLaterCallSendsIt() { 58 answer = { 200 to STATE } 59 val cloud = cloud() 60 val state = cloud.state() 61 assertEquals("dev123", device) 62 assertNull(seen[0].cookie) 63 cloud.state() 64 assertEquals("subplz_device=dev123", seen[1].cookie) 65 assertEquals(Cloud.State("acct_1", 7200, true, listOf(Cloud.Pack("captions20", "20 hours of cloud captions", 20, "$4.99"))), state) 66 } 67 68 @Test 69 fun aPieceGoesAsPcmWithItsLanguage() { 70 device = "dev123" 71 answer = { 200 to """{"text": "こんにちは", "seconds": 3, "seconds_left": 57}""" } 72 val pcm = byteArrayOf(1, 2, 3, 4) 73 val result = cloud().transcribe(pcm, "ja") 74 assertEquals(Cloud.Answer("こんにちは", 57), result) 75 assertEquals("POST", seen[0].method) 76 assertEquals("lang=ja", seen[0].query) 77 assertArrayEquals(pcm, seen[0].body) 78 } 79 80 @Test 81 fun noHoursLeftIsOutOfHours() { 82 device = "dev123" 83 answer = { 402 to """{"detail": "No cloud caption hours left."}""" } 84 try { 85 cloud().transcribe(ByteArray(2), "ja") 86 fail("no exception") 87 } catch (_: Cloud.OutOfHours) { 88 } 89 } 90 91 @Test 92 fun aServerErrorLosesOnlyThePiece() { 93 device = "dev123" 94 answer = { 502 to """{"detail": "speech service answered 500"}""" } 95 try { 96 cloud().transcribe(ByteArray(2), "ja") 97 fail("no exception") 98 } catch (e: Cloud.PieceFailed) { 99 assertEquals("Cloud: speech service answered 500.", e.message) 100 } 101 } 102 103 @Test 104 fun noServerLosesOnlyThePiece() { 105 val cloud = cloud() 106 server.stop(0) 107 try { 108 cloud.transcribe(ByteArray(2), "ja") 109 fail("no exception") 110 } catch (_: Cloud.PieceFailed) { 111 } 112 } 113 114 @Test 115 fun checkoutGivesThePaymentPage() { 116 answer = { 200 to """{"url": "https://checkout.stripe.com/c/pay/cs_1"}""" } 117 assertEquals("https://checkout.stripe.com/c/pay/cs_1", cloud().checkout("captions20")) 118 assertEquals("""{"pack_id":"captions20"}""", String(seen[0].body)) 119 } 120 121 @Test 122 fun theDeviceComesOnlyFromItsOwnCookie() { 123 assertEquals("abc", Cloud.deviceFrom(listOf("other=1; Path=/", "subplz_device=abc; HttpOnly"))) 124 assertNull(Cloud.deviceFrom(listOf("subplz_device=; Max-Age=0"))) 125 assertNull(Cloud.deviceFrom(emptyList())) 126 } 127 128 @Test 129 fun hoursReadAsHoursAndMinutes() { 130 assertEquals("0 min", Cloud.hours(59)) 131 assertEquals("59 min", Cloud.hours(3599)) 132 assertEquals("20 h 0 min", Cloud.hours(72000)) 133 assertEquals("1 h 1 min", Cloud.hours(3660)) 134 } 135 136 /** What goes to the cloud comes back as the same sound, within one step of 16-bit PCM. */ 137 @Test 138 fun pcmOfFloatsReadsBackTheSameSound(): Unit = runBlocking { 139 checkAll(Arb.list(Arb.float(-1f, 1f), 0..400)) { list -> 140 val samples = list.toFloatArray() 141 val bytes = Pcm.toBytes(samples) 142 val back = FloatArray(samples.size) 143 assertEquals(samples.size, Pcm.toFloat(bytes, bytes.size, back)) 144 for (i in samples.indices) assertTrue("${samples[i]} -> ${back[i]}", abs(samples[i] - back[i]) <= 1f / 32768f) 145 } 146 } 147 148 @Test 149 fun loudSoundIsClippedNotWrapped() { 150 val back = FloatArray(2) 151 Pcm.toFloat(Pcm.toBytes(floatArrayOf(1.5f, -1.5f)), 4, back) 152 assertEquals(32767f / 32768f, back[0]) 153 assertEquals(-1f, back[1]) 154 } 155 156 /** The engine stops asking the cloud once the hours are gone. */ 157 @Test 158 fun theEngineStopsWhenTheHoursAreGone() { 159 val calls = CopyOnWriteArrayList<Int>() 160 val states = CopyOnWriteArrayList<String>() 161 val captions = CopyOnWriteArrayList<String>() 162 val recognizer = object : Recognizer { 163 override val detectedLanguage = "" 164 override fun transcribe(samples: FloatArray, language: String): String { 165 calls += samples.size 166 if (calls.size > 1) throw Cloud.OutOfHours() 167 return "一つ目" 168 } 169 override fun close() = Unit 170 } 171 val engine = Engine({ recognizer }, "ja", { captions += it.text }, {}, { states += it }) 172 engine.start() 173 val random = Random(7) 174 repeat(3) { feed(engine, speech(1_500, random) + FloatArray(16_000)) } 175 engine.stop() 176 assertEquals(listOf("一つ目"), captions) 177 assertEquals(2, calls.size) 178 assertTrue(states.toString(), states.last().startsWith(Engine.STATE_ERROR + "No cloud hours left")) 179 } 180 181 private fun speech(ms: Int, random: Random) = FloatArray(16 * ms) { random.nextFloat() * 0.6f - 0.3f } 182 183 /** Feeds [sound] in frames of 100 ms, and waits until the worker took each piece. */ 184 private fun feed(engine: Engine, sound: FloatArray) { 185 val bytes = Pcm.toBytes(sound) 186 var at = 0 187 while (at < bytes.size) { 188 val n = minOf(3_200, bytes.size - at) 189 engine.feed(bytes.copyOfRange(at, at + n), n) 190 at += n 191 } 192 Thread.sleep(300) 193 } 194 195 private companion object { 196 const val STATE = """{"account_id": "acct_1", "seconds_left": 7200, "available": true, 197 "packs": [{"id": "captions20", "name": "20 hours of cloud captions", "hours": 20, "price_display": "$4.99"}]}""" 198 } 199 }