返回 CodeWhale
PetNativeTest.kt
根目录 / pet / android / src / androidTest / kotlin / codewhale / pet / PetNativeTest.kt
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
262 lines Plain Text