| 1 | package codewhale.pet |
| 2 | |
| 3 | import androidx.test.ext.junit.runners.AndroidJUnit4 |
| 4 | import androidx.test.platform.app.InstrumentationRegistry |
| 5 | import java.io.ByteArrayInputStream |
| 6 | import java.io.ByteArrayOutputStream |
| 7 | import java.io.File |
| 8 | import org.json.JSONArray |
| 9 | import org.json.JSONObject |
| 10 | import org.junit.Assert.* |
| 11 | import org.junit.Test |
| 12 | import org.junit.runner.RunWith |
| 13 | |
| 14 | @RunWith(AndroidJUnit4::class) |
| 15 | class PetNativeTest { |
| 16 | private val context = InstrumentationRegistry.getInstrumentation().targetContext |
| 17 | private fun asset(name: String) = context.assets.open(name).bufferedReader().use { it.readText() } |
| 18 | private fun core(saved: String? = null) = PetNativeCore(asset("pet-native.js"), asset("whale-points.tsv"), asset("demo.jsonl"), saved) |
| 19 | |
| 20 | @Test fun liveResumeKeepsNativeGeometryAlignedAndDoesNotReplayHumanInput() { |
| 21 | val human = asset("demo.jsonl").lineSequence().filter { it.isNotBlank() }.map(::JSONObject) |
| 22 | .first { it.getString("channel") == "human" && it.getBoolean("waiting") }.toString() |
| 23 | fun packet(sequence: Int) = JSONObject(human).put("sequence", sequence).put("simTimeMs", sequence * 400).toString() + "\n" |
| 24 | PetNativeCore(asset("pet-native.js"), asset("whale-points.tsv"), live = true).use { world -> |
| 25 | repeat(90) { world.tick(true) } |
| 26 | assertFalse(world.acceptLiveTail(packet(10))) |
| 27 | assertTrue(world.acceptLiveTail(packet(11))) |
| 28 | repeat(12) { world.tick(true) } |
| 29 | assertEquals("human", world.scene().state.channel) |
| 30 | val before = world.timeMs |
| 31 | world.resumeLiveInput() |
| 32 | assertTrue(world.timeMs - before in 0.0..800.0) |
| 33 | assertEquals(0.0, world.scene().state.observed, 0.0) |
| 34 | assertEquals("none", world.scene().needs) |
| 35 | assertEquals(0, world.voices.length()) |
| 36 | assertEquals(world.scene().digest, petDigest(world.sim)) |
| 37 | assertFalse(world.acceptLiveTail(packet(12))) |
| 38 | assertTrue(world.acceptLiveTail(packet(13))) |
| 39 | repeat(12) { assertEquals(world.tick(true).digest, petDigest(world.sim)) } |
| 40 | assertEquals("human", world.scene().state.channel) |
| 41 | PetNativeCore(asset("pet-native.js"), asset("whale-points.tsv"), saved = world.recording(), live = true).use { restored -> |
| 42 | assertEquals(0.0, restored.scene().state.observed, 0.0) |
| 43 | assertEquals(restored.scene().digest, petDigest(restored.sim)) |
| 44 | assertFalse(restored.acceptLiveTail(packet(14))) |
| 45 | } |
| 46 | } |
| 47 | } |
| 48 | |
| 49 | @Test fun nativeRendererMatchesEverySharedWorldFrameAndResumesCheckpoint() { |
| 50 | for (motion in listOf(true, false)) { |
| 51 | core().use { world -> |
| 52 | var resumed: PetNativeCore? = null |
| 53 | try { |
| 54 | var voices = 0 |
| 55 | for (tick in 0 until 2400) { |
| 56 | if (tick == 400) world.interact(true) |
| 57 | val frame = world.tick(motion) |
| 58 | voices += world.voices.length() |
| 59 | assertEquals("Kotlin vs JS: $motion / $tick", frame.digest, petDigest(world.sim)) |
| 60 | resumed?.let { |
| 61 | val replay = it.tick(motion) |
| 62 | assertEquals("Checkpoint continuation: $motion / $tick", frame.digest, replay.digest) |
| 63 | assertEquals(frame.behaviour, replay.behaviour) |
| 64 | assertEquals(world.voices.toString(), it.voices.toString()) |
| 65 | assertEquals(replay.digest, petDigest(it.sim)) |
| 66 | } |
| 67 | if (tick == 899) resumed = core(world.recording()) |
| 68 | } |
| 69 | assertTrue("Fixture includes the actual score", voices > 0) |
| 70 | } finally { resumed?.close() } |
| 71 | } |
| 72 | } |
| 73 | } |
| 74 | |
| 75 | @Test fun invalidAndLegacyRecordingsRespectTheVersionBoundary() { |
| 76 | core().use { original -> |
| 77 | val before = original.recording() |
| 78 | val bad = JSONObject(before).put("expressionVersion", 3).toString() |
| 79 | assertThrows(Exception::class.java) { core(bad).close() } |
| 80 | val badBody = JSONObject(before) |
| 81 | badBody.getJSONObject("checkpoint").getJSONObject("sim").getJSONArray("body").getJSONArray(0).put(0, 0.99) |
| 82 | assertThrows(Exception::class.java) { core(badBody.toString()).close() } |
| 83 | assertEquals(before, original.recording()) |
| 84 | val exported = JSONObject(before).apply { remove("checkpoint") }.toString() |
| 85 | core(exported).use { replay -> |
| 86 | assertEquals(2, replay.sim.expressionVersion) |
| 87 | assertEquals(0.0, replay.timeMs, 0.0) |
| 88 | repeat(120) { assertEquals(replay.tick(true).digest, petDigest(replay.sim)) } |
| 89 | } |
| 90 | // At t=0 both expression versions have the identical home body. |
| 91 | val legacy = JSONObject(before).apply { |
| 92 | put("petReplayVersion", 1); remove("start") |
| 93 | remove("expressionVersion") |
| 94 | getJSONObject("checkpoint").apply { |
| 95 | put("petCheckpointVersion", 1); remove("historyStart"); remove("hasTelemetry") |
| 96 | getJSONObject("sim").remove("expressionVersion") |
| 97 | } |
| 98 | } |
| 99 | core(legacy.toString()).use { restored -> |
| 100 | assertEquals(1, restored.sim.expressionVersion) |
| 101 | repeat(120) { assertEquals(restored.tick(true).digest, petDigest(restored.sim)) } |
| 102 | } |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | @Test fun nativeAudioCursorMatchesWholeScoreWithoutReplayingPastVoices() { |
| 107 | core().use { world -> |
| 108 | val cursor = PetAudioCursor(world.timeMs) |
| 109 | val all = JSONArray() |
| 110 | val chunks = mutableListOf<FloatArray>() |
| 111 | repeat(30) { |
| 112 | world.tick(true) |
| 113 | for (i in 0 until world.voices.length()) all.put(world.voices.getJSONObject(i)) |
| 114 | chunks += checkNotNull(cursor.next(world)) |
| 115 | } |
| 116 | val received = FloatArray(chunks.sumOf { it.size }) |
| 117 | var offset = 0 |
| 118 | chunks.forEach { it.copyInto(received, offset); offset += it.size } |
| 119 | val whole = world.pcm(all, 0, 24_000) + world.pcm(all, 24_000, 24_000) |
| 120 | assertArrayEquals(whole, received, 0f) |
| 121 | assertTrue(whole.any { kotlin.math.abs(it) > 0.001f }) |
| 122 | assertNull(PetAudioCursor(world.timeMs).next(world)) |
| 123 | assertThrows(IllegalArgumentException::class.java) { world.pcm(all, -1, 100) } |
| 124 | assertThrows(IllegalArgumentException::class.java) { world.pcm(all, 0, 24_001) } |
| 125 | } |
| 126 | } |
| 127 | |
| 128 | @Test fun longParticleClocksRestoreAndContinueInBothEngines() { |
| 129 | // Construct a long-clock boundary fixture; this is not a 30-hour device run. |
| 130 | core().use { original -> |
| 131 | val saved = JSONObject(original.recording()) |
| 132 | val particle = saved.getJSONObject("checkpoint").getJSONObject("sim") |
| 133 | particle.put("clock", 108_000.0).put("phase", 108_000.0) |
| 134 | particle.getJSONArray("particles").getJSONArray(0).put(4, 1_000_001.0) |
| 135 | core(saved.toString()).use { restored -> |
| 136 | repeat(60) { assertEquals(restored.tick(true).digest, petDigest(restored.sim)) } |
| 137 | core(restored.recording()).use { again -> |
| 138 | repeat(60) { assertEquals(restored.tick(true).digest, again.tick(true).digest) } |
| 139 | } |
| 140 | } |
| 141 | particle.put("clock", 100.0 * 365.0 * 86_400.0 + 1.0) |
| 142 | assertThrows(Exception::class.java) { core(saved.toString()).close() } |
| 143 | } |
| 144 | } |
| 145 | |
| 146 | @Test fun exportRetainsTheExactUnsavedVisitAfterAStoreConflict() { |
| 147 | val directory = File(context.cacheDir, "pet-export-${java.util.UUID.randomUUID()}").apply { mkdirs() } |
| 148 | try { |
| 149 | core().use { world -> |
| 150 | val store = PetHabitatStore(directory, "wild") |
| 151 | assertNull(store.read()); store.save(world.recording()) |
| 152 | repeat(31) { world.tick(true) } |
| 153 | world.interact(true) |
| 154 | File(directory, "pet-wild.json").writeText("other writer") |
| 155 | assertThrows(IllegalStateException::class.java) { store.save(world.recording()) } |
| 156 | val output = ByteArrayOutputStream() |
| 157 | assertEquals(world.exportRecording(output), output.size().toLong()) |
| 158 | val saved = output.toString("UTF-8") |
| 159 | assertEquals(world.recording(), saved) |
| 160 | core(saved).use { restored -> |
| 161 | assertEquals(world.timeMs, restored.timeMs, 0.0) |
| 162 | repeat(60) { assertEquals(world.tick(true).digest, restored.tick(true).digest) } |
| 163 | } |
| 164 | assertEquals("other writer", File(directory, "pet-wild.json").readText()) |
| 165 | } |
| 166 | } finally { directory.deleteRecursively() } |
| 167 | } |
| 168 | |
| 169 | @Test fun aRecordingBeyondTheAutosaveLimitCanStillBeExportedInChunks() { |
| 170 | // Populate through the actual public interaction API, inside the same |
| 171 | // 64 MiB QuickJS heap. The subclass only seeds this large input fixture. |
| 172 | val fixture = asset("pet-native.js") + """ |
| 173 | globalThis.PetNative = class extends globalThis.PetNative { |
| 174 | constructor(...args) { |
| 175 | super(...args); |
| 176 | for (let i = 0; i < 90000; i++) this.interact('attention', 0.3141592653589793, -0.2718281828459045); |
| 177 | } |
| 178 | }; |
| 179 | """.trimIndent() |
| 180 | PetNativeCore(fixture, asset("whale-points.tsv")).use { world -> |
| 181 | assertThrows(IllegalArgumentException::class.java) { world.recording() } |
| 182 | val file = File.createTempFile("pet-large-export-", ".json", context.cacheDir) |
| 183 | try { |
| 184 | val size = file.outputStream().buffered().use { world.exportRecording(it) } |
| 185 | assertEquals(size, file.length()) |
| 186 | assertTrue(size > PetNativeCore.MAX_HABITAT_BYTES) |
| 187 | val recording = JSONObject(file.readText()) |
| 188 | assertEquals(90000, recording.getJSONArray("interactions").length()) |
| 189 | assertEquals(0, recording.getJSONObject("checkpoint").getInt("tick")) |
| 190 | assertEquals(2, recording.getInt("expressionVersion")) |
| 191 | // Export has not advanced or discarded the in-memory visit. |
| 192 | assertEquals(0.0, world.timeMs, 0.0) |
| 193 | } finally { file.delete() } |
| 194 | } |
| 195 | } |
| 196 | |
| 197 | @Test fun atomicStorageRefusesConflictingWritersAndOversizedOrInvalidUtf8Input() { |
| 198 | val directory = File(context.cacheDir, "pet-store-${java.util.UUID.randomUUID()}").apply { mkdirs() } |
| 199 | try { |
| 200 | val a = PetHabitatStore(directory, "wild") |
| 201 | val b = PetHabitatStore(directory, "wild") |
| 202 | assertNull(a.read()); assertNull(b.read()) |
| 203 | a.save("first") |
| 204 | assertThrows(IllegalStateException::class.java) { b.save("stale") } |
| 205 | assertEquals("first", b.read()) |
| 206 | b.save("second") |
| 207 | assertThrows(IllegalStateException::class.java) { a.save("stale") } |
| 208 | assertEquals("second", a.read()) |
| 209 | // A malformed UTF-8 original can still be preserved and restarted. |
| 210 | val damaged = File(directory, "pet-demo.json") |
| 211 | val bytes = byteArrayOf(0xc3.toByte(), 0x28) |
| 212 | damaged.writeBytes(bytes) |
| 213 | val recovery = PetHabitatStore(directory, "demo") |
| 214 | assertThrows(java.nio.charset.CharacterCodingException::class.java) { recovery.read() } |
| 215 | val backup = recovery.restart("fresh")!! |
| 216 | assertArrayEquals(bytes, backup.readBytes()) |
| 217 | assertEquals("fresh", recovery.read()) |
| 218 | assertThrows(IllegalArgumentException::class.java) { |
| 219 | PetHabitatStore.boundedRead(ByteArrayInputStream(ByteArray(PetNativeCore.MAX_HABITAT_BYTES + 1))) |
| 220 | } |
| 221 | assertThrows(java.nio.charset.CharacterCodingException::class.java) { |
| 222 | PetHabitatStore.boundedRead(ByteArrayInputStream(byteArrayOf(0xc3.toByte(), 0x28))) |
| 223 | } |
| 224 | assertEquals("second", a.read()) |
| 225 | } finally { directory.deleteRecursively() } |
| 226 | } |
| 227 | |
| 228 | @Test fun rotationArchivesBeforeRetiringHistoryAndKeepsTheWorldOnAStorageConflict() { |
| 229 | val directory = File(context.cacheDir, "pet-segment-${java.util.UUID.randomUUID()}").apply { mkdirs() } |
| 230 | val seeded = asset("pet-native.js") + "\nconst SegmentBase = PetNative; PetNative = class extends SegmentBase { constructor(...args) { super(...args); for(let i=0;i<4096;i++) this.interact('food', .2, -.15); } };" |
| 231 | try { |
| 232 | PetNativeCore(seeded, asset("whale-points.tsv")).use { world -> |
| 233 | world.tick(false) |
| 234 | val original = world.recording(); val before = world.scene() |
| 235 | val next = world.prepareSegment()!! |
| 236 | val staged = File(directory, "staged.json") |
| 237 | staged.outputStream().use { world.exportRecording(it, completed = true) } |
| 238 | val store = PetHabitatStore(directory, "wild") |
| 239 | assertNull(store.read()); store.save(original) |
| 240 | val habitat = File(directory, "pet-wild.json") |
| 241 | habitat.writeText("another writer") |
| 242 | assertThrows(IllegalStateException::class.java) { store.save(next, staged, 1) } |
| 243 | assertEquals(original, world.recording()); assertTrue(store.archives().isEmpty()) |
| 244 | assertEquals("another writer", habitat.readText()) |
| 245 | habitat.writeText(original) |
| 246 | store.save(next, staged, 1); world.commitSegment() |
| 247 | assertEquals(before.digest, world.scene().digest) |
| 248 | assertTrue(world.recording().length < original.length) |
| 249 | assertEquals(1, store.archives().size) |
| 250 | assertEquals(original, store.archive(store.archives().single()).readText()) |
| 251 | core(store.read()).use { restored -> |
| 252 | assertEquals(before.digest, restored.scene().digest) |
| 253 | repeat(60) { assertEquals(world.tick(true).digest, restored.tick(true).digest) } |
| 254 | } |
| 255 | val archive = store.archive(store.archives().single()); archive.writeText("damaged") |
| 256 | assertThrows(IllegalStateException::class.java) { store.archive(store.archives().single()) } |
| 257 | assertEquals("damaged", archive.readText()) |
| 258 | } |
| 259 | } finally { directory.deleteRecursively() } |
| 260 | } |
| 261 | } |
| 262 |