Skip to content

Commit e78e21b

Browse files
Staacksclaude
andcommitted
Bluetooth compatibility tests, and a pin for the debug remote seam.
The unattended-run seam already covers experiments delivered by a Bluetooth device: every BLE route ends at Experiment.onExperimentLoaded through ExperimentListActivity, so the DebugSwitches.remoteEnabled() check there applies to them as much as to a launched experiment. That is easy to break by accident, though, so pin it: the first test opens a bundled Bluetooth experiment with the switch set and insists the remote API answers, which fails with a message naming the consequence if the seam is ever moved or narrowed. The second test is the board test itself and needs hardware, so it takes the device name as an instrumentation argument (-e bleDevice) and skips without it. It goes through the real menu - by resource id, because the labels are translated - waits for the device to be discovered, loads the experiment it offers and asserts it arrives stopped rather than running. It has been verified to compile and to skip cleanly; it has not yet run against an Arduino or MicroPython board. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent ce9fe1b commit e78e21b

1 file changed

Lines changed: 194 additions & 0 deletions

File tree

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
package de.rwth_aachen.phyphox;
2+
3+
import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation;
4+
import static org.junit.Assert.assertFalse;
5+
import static org.junit.Assert.assertNotNull;
6+
import static org.junit.Assert.assertTrue;
7+
import static org.junit.Assume.assumeTrue;
8+
9+
import android.content.Context;
10+
import android.content.Intent;
11+
12+
import androidx.test.ext.junit.runners.AndroidJUnit4;
13+
import androidx.test.platform.app.InstrumentationRegistry;
14+
import androidx.test.uiautomator.By;
15+
import androidx.test.uiautomator.StaleObjectException;
16+
import androidx.test.uiautomator.UiDevice;
17+
import androidx.test.uiautomator.UiObject2;
18+
import androidx.test.uiautomator.Until;
19+
20+
import org.junit.Before;
21+
import org.junit.Test;
22+
import org.junit.runner.RunWith;
23+
24+
// phyphox-test: ble-compat-arduino
25+
// phyphox-test: ble-compat-micropython
26+
//The UI half of the BLE compatibility suite, and deliberately only that half: picking a device
27+
//out of a scan has no equivalent over the remote API, so it has to be driven here, while
28+
//everything that follows - the values, the rates, what the board actually emits - is asserted
29+
//from the host against the shared expectations (phyphox-docs/tools/lab/ble.py and
30+
//fixtures/ble/scenarios.yml). Duplicating those assertions in two languages would let them
31+
//drift; this keeps them in one file and still exercises the real scan UI, which is part of what
32+
//the suite protects.
33+
//
34+
//So this test ends where the host takes over: the experiment the device offers is loaded, not
35+
//started, and serving the remote API. It asserts nothing about the data.
36+
//
37+
//The device name comes from the driver, which flashes the library examples UNMODIFIED and knows
38+
//what they advertise as:
39+
//
40+
// adb shell am instrument -e class de.rwth_aachen.phyphox.BleCompatTest \
41+
// -e bleDevice phyphox-arduino ...
42+
//
43+
//Without that parameter there is no board to talk to and that test skips itself, which is what
44+
//happens in CI - the row needs hardware and runs in the lab. The seam check below needs none and
45+
//runs everywhere, because a suite that cannot reach the phone is worth catching before the board
46+
//is even involved.
47+
@RunWith(AndroidJUnit4.class)
48+
public class BleCompatTest {
49+
50+
private static final String PACKAGE = "de.rwth_aachen.phyphox";
51+
private static final int PORT = 8080;
52+
53+
private UiDevice device() {
54+
return UiDevice.getInstance(getInstrumentation());
55+
}
56+
57+
private UiObject2 waitForId(String id, long timeout) {
58+
return device().wait(Until.findObject(By.res(PACKAGE + ":id/" + id)), timeout);
59+
}
60+
61+
@Before
62+
public void quietFirstRunDialogs() {
63+
FixtureExperiment.suppressHints();
64+
}
65+
66+
//The precondition the whole suite rests on, and the one half of it that needs no board: an
67+
//experiment with a Bluetooth block serves the remote API when the switch is set, so the host
68+
//can reach it. The switch is applied where every experiment finishes loading
69+
//(Experiment.onExperimentLoaded), which is the same place a transferred one arrives at, so
70+
//this covers the delivered case too as far as it can be covered without hardware.
71+
@Test
72+
public void aBluetoothExperimentServesTheRemoteApiWhenTheSwitchIsSet() throws Exception {
73+
shell("setprop debug.phyphox.remote 1");
74+
shell("setprop debug.phyphox.remotePort " + PORT);
75+
try {
76+
FixtureExperiment.launchAssetWithoutWaiting("bluetooth/Heart Rate.phyphox");
77+
//It stops at "please pick a device" without one, which is exactly the state the host
78+
//finds a transferred experiment in before it starts it - and the API has to answer
79+
//there, not only once something is connected.
80+
long deadline = System.currentTimeMillis() + 30000;
81+
boolean answered = false;
82+
while (!answered && System.currentTimeMillis() < deadline) {
83+
answered = remoteApiAnswers();
84+
if (!answered)
85+
Thread.sleep(500);
86+
}
87+
assertTrue("the remote API did not come up for a Bluetooth experiment although "
88+
+ "debug.phyphox.remote is set - the host cannot reach a device-delivered "
89+
+ "experiment either", answered);
90+
} finally {
91+
shell("setprop debug.phyphox.remote '\"\"'");
92+
shell("setprop debug.phyphox.remotePort '\"\"'");
93+
FixtureExperiment.close(FixtureExperiment.activity());
94+
}
95+
}
96+
97+
private void shell(String command) throws Exception {
98+
device().executeShellCommand(command);
99+
}
100+
101+
private boolean remoteApiAnswers() {
102+
try {
103+
java.net.HttpURLConnection connection = (java.net.HttpURLConnection)
104+
new java.net.URL("http://127.0.0.1:" + PORT + "/config").openConnection();
105+
connection.setConnectTimeout(2000);
106+
connection.setReadTimeout(2000);
107+
try (java.io.InputStream in = connection.getInputStream()) {
108+
return in.read() > 0;
109+
} finally {
110+
connection.disconnect();
111+
}
112+
} catch (Exception e) {
113+
return false;
114+
}
115+
}
116+
117+
@Test
118+
public void theDeviceOffersItsExperimentAndItLoads() throws Exception {
119+
String name = InstrumentationRegistry.getArguments().getString("bleDevice");
120+
assumeTrue("no bleDevice given - this row needs a board and runs in the lab",
121+
name != null && !name.trim().isEmpty());
122+
name = name.trim();
123+
124+
//The collection, where the scan lives.
125+
Context app = getInstrumentation().getTargetContext();
126+
Intent intent = app.getPackageManager().getLaunchIntentForPackage(app.getPackageName());
127+
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
128+
app.startActivity(intent);
129+
130+
//By resource id, not by label: the labels are translated and the run may be in any
131+
//language, but the ids are the same in every build.
132+
UiObject2 fab = waitForId("newExperiment", 20000);
133+
assertNotNull("the collection did not come up with its new-experiment button", fab);
134+
fab.click();
135+
UiObject2 bluetooth = waitForId("newExperimentBluetooth", 10000);
136+
assertNotNull("the new-experiment menu has no Bluetooth entry", bluetooth);
137+
bluetooth.click();
138+
139+
//The scan needs a moment to find anything, and there is deliberately more than one board
140+
//advertising: taking the first entry would pass against the wrong device, so this waits
141+
//for the name the driver asked for and for nothing else.
142+
UiObject2 entry = device().wait(Until.findObject(By.text(name)), 45000);
143+
assertNotNull("the scan did not list a device called \"" + name + "\" within 45 s - is it "
144+
+ "powered and advertising?", entry);
145+
try {
146+
entry.click();
147+
} catch (StaleObjectException e) {
148+
//The scan list rebuilds as devices come and go; look the entry up once more.
149+
entry = device().wait(Until.findObject(By.text(name)), 10000);
150+
assertNotNull("\"" + name + "\" disappeared from the scan before it could be picked",
151+
entry);
152+
entry.click();
153+
}
154+
155+
//A device that both offers its own experiment and matches bundled ones asks which to use.
156+
//A board running an unmodified library example offers only its own, and phyphox opens it
157+
//without asking - so this dialog is optional, and only its "load from device" choice is
158+
//the one under test.
159+
UiObject2 loadFromDevice = device().wait(Until.findObject(
160+
By.text(app.getString(R.string.newExperimentBluetoothLoadFromDevice))), 5000);
161+
if (loadFromDevice != null)
162+
loadFromDevice.click();
163+
164+
//The transfer runs over BLE and is slow on purpose - the experiment is chunked over a
165+
//characteristic - so this is the one wait worth being generous about.
166+
Experiment experiment = awaitLoaded(90000);
167+
assertNotNull("the experiment the device offers did not load within 90 s", experiment);
168+
assertTrue("the experiment arrived but did not parse: " + experiment.experiment.message,
169+
experiment.experiment.loaded);
170+
171+
//Left loaded and not started: the host starts it over the remote API and does the
172+
//measuring, because that is where the shared expectations live.
173+
assertFalse("the experiment must be left for the host to start, not started here",
174+
experiment.measuring);
175+
}
176+
177+
//FixtureExperiment.awaitLoaded has a fixed deadline and throws; the BLE transfer needs its
178+
//own, and a null lets the assertion above say what actually went wrong.
179+
private Experiment awaitLoaded(long millis) {
180+
long deadline = System.currentTimeMillis() + millis;
181+
while (System.currentTimeMillis() < deadline) {
182+
Experiment activity = FixtureExperiment.activity();
183+
if (activity != null && activity.experiment != null && activity.experiment.loaded)
184+
return activity;
185+
try {
186+
Thread.sleep(250);
187+
} catch (InterruptedException e) {
188+
Thread.currentThread().interrupt();
189+
break;
190+
}
191+
}
192+
return null;
193+
}
194+
}

0 commit comments

Comments
 (0)