package com.sptmobile.link import android.util.Base64 import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.sptmobile.endpoint.DigestViewState import com.sptmobile.endpoint.Timeline import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Assume.assumeTrue import org.junit.Test import org.junit.runner.RunWith // [int->REQ-DEVICE-LINK-IROH] /** * The Q3 on-device proof (APP-PLAN.md): load the real .so inside a real * Android runtime and drive a real host over the emulator's NAT. Driven by * `ci/emulator-smoke.ps1`, which boots the AVD, starts `spt-mobile-host run` * on the workstation, and passes the host's `device link addr:` beacon * (loopback rewritten to 10.0.2.2) + pairing token as instrumentation args. * Without those args the test SKIPS — `gradlew connectedDebugAndroidTest` * alone stays green. */ @RunWith(AndroidJUnit4::class) class LinkLoopbackSmokeTest { @Test fun connect_pair_ping_list_over_real_udp() { val args = InstrumentationRegistry.getArguments() // Base64 because EndpointAddr JSON does not survive `adb shell am // instrument -e` quoting. val addrB64 = args.getString("sptHostAddrB64") val token = args.getString("sptToken") assumeTrue("no loopback host provided — skipping smoke", addrB64 != null && token != null) LinkNative.init(InstrumentationRegistry.getInstrumentation().targetContext) val addrJson = String(Base64.decode(addrB64, Base64.DEFAULT), Charsets.UTF_8) val handle = LinkNative.connectTo(addrJson, token!!) try { val pairReply = LinkNative.pair(handle, "emulator-smoke") assertTrue("pair reply carries the endpoint", pairReply.contains("endpoint")) LinkNative.ping(handle) val listing = LinkNative.listEndpoints(handle) assertTrue("endpoint listing is JSON", listing.trimStart().startsWith("{")) // [int->REQ-INBOUND-NOTIFS] The W5 drain seam over real UDP: // pair registered this device's spool above, so a drain answers // with a JSON array (empty — nothing spooled) rather than an // unknown-device error. val drained = SpoolInbox.parse(LinkNative.spoolDrain(handle, "emulator-smoke")) assertTrue("fresh device's spool drains empty", drained.isEmpty()) } finally { LinkNative.close(handle) } } // [int->REQ-ENDPOINT-VIEW-INTERLACE] /** * The W4 endpoint-view seam on-device: history + snapshot + follow + * send against the stub-backed host, fed through the SAME view state and * interlace the screen uses ([DigestViewState]/[Timeline]). */ @Test fun endpoint_view_seam_over_real_udp() { val args = InstrumentationRegistry.getArguments() val addrB64 = args.getString("sptHostAddrB64") val token = args.getString("sptToken") assumeTrue("no loopback host provided — skipping smoke", addrB64 != null && token != null) LinkNative.init(InstrumentationRegistry.getInstrumentation().targetContext) val addrJson = String(Base64.decode(addrB64, Base64.DEFAULT), Charsets.UTF_8) val handle = LinkNative.connectTo(addrJson, token!!) try { LinkNative.pair(handle, "emulator-smoke-view") // Send first so history has at least our own row. val sendReply = LinkNative.send( handle, "star-1", "mobile-gw", """{"msg-id":"smoke-1"}""", "hello from smoke", ) val outcome = Timeline.parseSendResult(sendReply) assertTrue("send is a final success (${outcome.outcome})", outcome.success) val history = Timeline.parseHistory( LinkNative.historyFetch(handle, "star-1", 50), ) assertTrue("history carries the send", history.any { it.msg_id == "smoke-1" }) val view = DigestViewState() view.noteOwnSend("smoke-1") view.applySnapshot(LinkNative.digestSnapshot(handle, "star-1", -1)) assertTrue("snapshot yields turns", view.turns.isNotEmpty()) // The stub's follow stream replays its deltas; the first frame // must decode as one and apply cleanly. val followHandle = LinkNative.digestFollow(handle, "star-1") try { val event = FollowEvent.decode(LinkNative.followNext(followHandle, 20_000)) assertTrue("first follow event is a delta, got $event", event is FollowEvent.Delta) view.applyDelta((event as FollowEvent.Delta).deltaJson) } finally { LinkNative.followClose(followHandle) } val rows = Timeline.interlace(history, emptyList(), view.turns, view.ownSends()) assertTrue("interlaced timeline is non-empty", rows.isNotEmpty()) assertEquals( "own send appears exactly once (echo collapsed)", 1, rows.count { r -> r is com.sptmobile.endpoint.TimelineRow.Message && r.msgId == "smoke-1" }, ) } finally { LinkNative.close(handle) } } }