Wire device execution mode controls into UI
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
import org.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.annotation.Config;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.ProtocolException;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class BossApiClientDeviceModeTest {
|
||||
@Test
|
||||
public void updateDevicePreferredExecutionModeWritesModeToPatchBody() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(
|
||||
new URL("https://boss.hyzq.net/api/v1/devices/device-1")
|
||||
);
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
apiClient.updateDevicePreferredExecutionMode("device-1", "gui");
|
||||
|
||||
assertEquals("/api/v1/devices/device-1", apiClient.lastPath);
|
||||
assertEquals("PATCH", connection.requestMethodValue);
|
||||
assertEquals("{\"preferredExecutionMode\":\"gui\"}", connection.requestBody());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void updateProjectConflictDecisionWritesProjectScopedPatchBody() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(
|
||||
new URL("https://boss.hyzq.net/api/v1/devices/device-1")
|
||||
);
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
apiClient.updateProjectConflictDecision("device-1", "thread-ui", "mac-studio:boss", "allow_always");
|
||||
|
||||
assertEquals("/api/v1/devices/device-1", apiClient.lastPath);
|
||||
assertEquals("PATCH", connection.requestMethodValue);
|
||||
assertEquals(
|
||||
"{\"projectId\":\"thread-ui\",\"folderKey\":\"mac-studio:boss\",\"conflictDecision\":\"allow_always\"}",
|
||||
connection.requestBody()
|
||||
);
|
||||
}
|
||||
|
||||
private static final class RecordingBossApiClient extends BossApiClient {
|
||||
private final RecordingConnection connection;
|
||||
private String lastPath = "";
|
||||
|
||||
RecordingBossApiClient(RecordingConnection connection) {
|
||||
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
|
||||
this.connection = connection;
|
||||
}
|
||||
|
||||
@Override
|
||||
HttpURLConnection openConnection(String path) {
|
||||
lastPath = path;
|
||||
return connection;
|
||||
}
|
||||
|
||||
@Override
|
||||
String encode(String value) {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
void rememberIdentity(JSONObject json) {
|
||||
// JVM 单测只关心 patch body。
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingConnection extends HttpURLConnection {
|
||||
private final ByteArrayOutputStream requestBody = new ByteArrayOutputStream();
|
||||
private final Map<String, String> requestHeaders = new HashMap<>();
|
||||
private String requestMethodValue = "GET";
|
||||
|
||||
RecordingConnection(URL url) {
|
||||
super(url);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void disconnect() {}
|
||||
|
||||
@Override
|
||||
public boolean usingProxy() {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void connect() {}
|
||||
|
||||
@Override
|
||||
public void setRequestMethod(String method) throws ProtocolException {
|
||||
requestMethodValue = method;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setRequestProperty(String key, String value) {
|
||||
requestHeaders.put(key, value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getRequestProperty(String key) {
|
||||
return requestHeaders.get(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public OutputStream getOutputStream() {
|
||||
return requestBody;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getResponseCode() {
|
||||
return 200;
|
||||
}
|
||||
|
||||
@Override
|
||||
public InputStream getInputStream() {
|
||||
return new ByteArrayInputStream("{\"ok\":true}".getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
String requestBody() {
|
||||
return requestBody.toString(StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class InMemorySharedPreferences implements SharedPreferences {
|
||||
private final Map<String, String> values = new HashMap<>();
|
||||
|
||||
@Override
|
||||
public Map<String, ?> getAll() {
|
||||
return Collections.unmodifiableMap(values);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getString(String key, String defValue) {
|
||||
return values.getOrDefault(key, defValue);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> getStringSet(String key, Set<String> defValues) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getInt(String key, int defValue) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public long getLong(String key, long defValue) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public float getFloat(String key, float defValue) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean getBoolean(String key, boolean defValue) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean contains(String key) {
|
||||
return values.containsKey(key);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor edit() {
|
||||
return new Editor() {
|
||||
@Override
|
||||
public Editor putString(String key, String value) {
|
||||
values.put(key, value);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor remove(String key) {
|
||||
values.remove(key);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor clear() {
|
||||
values.clear();
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void apply() {}
|
||||
|
||||
@Override
|
||||
public boolean commit() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor putStringSet(String key, Set<String> values) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor putInt(String key, int value) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor putLong(String key, long value) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor putFloat(String key, float value) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Editor putBoolean(String key, boolean value) {
|
||||
throw new UnsupportedOperationException();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {}
|
||||
|
||||
@Override
|
||||
public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
@@ -8,6 +11,8 @@ import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.TextView;
|
||||
|
||||
import androidx.appcompat.app.AlertDialog;
|
||||
|
||||
import org.json.JSONArray;
|
||||
import org.json.JSONObject;
|
||||
import org.junit.Test;
|
||||
@@ -15,8 +20,14 @@ import org.junit.runner.RunWith;
|
||||
import org.robolectric.Robolectric;
|
||||
import org.robolectric.RobolectricTestRunner;
|
||||
import org.robolectric.annotation.Config;
|
||||
import org.robolectric.shadows.ShadowDialog;
|
||||
import org.robolectric.util.ReflectionHelpers;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.AbstractExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class DeviceDetailActivityTest {
|
||||
@@ -37,6 +48,144 @@ public class DeviceDetailActivityTest {
|
||||
assertFalse(viewTreeContainsText(content, "让主 Agent 主动询问这台设备上的活跃项目目标、进度和架构"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void renderDeviceShowsGuiCliCapabilitiesAndPreferredExecutionMode() throws Exception {
|
||||
TestDeviceDetailActivity activity = Robolectric
|
||||
.buildActivity(
|
||||
TestDeviceDetailActivity.class,
|
||||
new Intent()
|
||||
.putExtra(DeviceDetailActivity.EXTRA_DEVICE_ID, "device-1")
|
||||
.putExtra(DeviceDetailActivity.EXTRA_DEVICE_NAME, "Mac Studio")
|
||||
)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderDevice",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildDevicePayload())
|
||||
);
|
||||
|
||||
View content = activity.findViewById(R.id.screen_content);
|
||||
assertTrue(viewTreeContainsText(content, "GUI 能力"));
|
||||
assertTrue(viewTreeContainsText(content, "CLI 能力"));
|
||||
assertTrue(viewTreeContainsText(content, "默认执行模式"));
|
||||
assertTrue(viewTreeContainsText(content, "当前默认:GUI"));
|
||||
assertTrue(viewTreeContainsText(content, "已连接"));
|
||||
assertTrue(viewTreeContainsText(content, "未连接"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void renderDeviceShowsProjectScopedConflictCardAndActions() throws Exception {
|
||||
TestDeviceDetailActivity activity = Robolectric
|
||||
.buildActivity(
|
||||
TestDeviceDetailActivity.class,
|
||||
new Intent()
|
||||
.putExtra(DeviceDetailActivity.EXTRA_DEVICE_ID, "device-1")
|
||||
.putExtra(DeviceDetailActivity.EXTRA_DEVICE_NAME, "Mac Studio")
|
||||
)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderDevice",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildConflictPayload())
|
||||
);
|
||||
|
||||
View content = activity.findViewById(R.id.screen_content);
|
||||
assertTrue(viewTreeContainsText(content, "异常项目 / 文件夹冲突"));
|
||||
assertTrue(viewTreeContainsText(content, "thread-ui"));
|
||||
assertTrue(viewTreeContainsText(content, "允许本次"));
|
||||
assertTrue(viewTreeContainsText(content, "存在并行风险"));
|
||||
assertTrue(viewTreeContainsText(content, "仅作用于当前异常项目 / 文件夹"));
|
||||
assertTrue(viewTreeContainsText(content, "冲突策略"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void preferredExecutionModeDialogPersistsSelectedMode() throws Exception {
|
||||
TestDeviceDetailActivity activity = Robolectric
|
||||
.buildActivity(
|
||||
TestDeviceDetailActivity.class,
|
||||
new Intent()
|
||||
.putExtra(DeviceDetailActivity.EXTRA_DEVICE_ID, "device-1")
|
||||
.putExtra(DeviceDetailActivity.EXTRA_DEVICE_NAME, "Mac Studio")
|
||||
)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(
|
||||
activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE),
|
||||
"https://boss.hyzq.net"
|
||||
);
|
||||
ReflectionHelpers.setField(activity, "apiClient", apiClient);
|
||||
ReflectionHelpers.setField(activity, "executor", new DirectExecutorService());
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"showPreferredExecutionModeDialog",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildDevicePayload())
|
||||
);
|
||||
|
||||
android.app.Dialog latestDialog = ShadowDialog.getLatestDialog();
|
||||
assertTrue(latestDialog instanceof AlertDialog);
|
||||
AlertDialog dialog = (AlertDialog) latestDialog;
|
||||
assertNotNull(dialog.getListView());
|
||||
dialog.getListView().performItemClick(
|
||||
dialog.getListView().getAdapter().getView(0, null, dialog.getListView()),
|
||||
0,
|
||||
dialog.getListView().getAdapter().getItemId(0)
|
||||
);
|
||||
|
||||
assertEquals(1, apiClient.updateDeviceCalls);
|
||||
assertEquals("device-1", apiClient.lastDeviceId);
|
||||
assertNotNull(apiClient.lastPayload);
|
||||
assertEquals("gui", apiClient.lastPayload.optString("preferredExecutionMode"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void conflictDecisionDialogPersistsScopedDecision() throws Exception {
|
||||
TestDeviceDetailActivity activity = Robolectric
|
||||
.buildActivity(
|
||||
TestDeviceDetailActivity.class,
|
||||
new Intent()
|
||||
.putExtra(DeviceDetailActivity.EXTRA_DEVICE_ID, "device-1")
|
||||
.putExtra(DeviceDetailActivity.EXTRA_DEVICE_NAME, "Mac Studio")
|
||||
)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(
|
||||
activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE),
|
||||
"https://boss.hyzq.net"
|
||||
);
|
||||
ReflectionHelpers.setField(activity, "apiClient", apiClient);
|
||||
ReflectionHelpers.setField(activity, "executor", new DirectExecutorService());
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"showConflictDecisionDialog",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildConflictPayload())
|
||||
);
|
||||
|
||||
android.app.Dialog latestDialog = ShadowDialog.getLatestDialog();
|
||||
assertTrue(latestDialog instanceof AlertDialog);
|
||||
AlertDialog dialog = (AlertDialog) latestDialog;
|
||||
assertNotNull(dialog.getListView());
|
||||
dialog.getListView().performItemClick(
|
||||
dialog.getListView().getAdapter().getView(2, null, dialog.getListView()),
|
||||
2,
|
||||
dialog.getListView().getAdapter().getItemId(2)
|
||||
);
|
||||
|
||||
assertEquals(1, apiClient.updateDeviceCalls);
|
||||
assertEquals("device-1", apiClient.lastDeviceId);
|
||||
assertNotNull(apiClient.lastPayload);
|
||||
assertEquals("thread-ui", apiClient.lastPayload.optString("projectId"));
|
||||
assertEquals("mac-studio:boss", apiClient.lastPayload.optString("folderKey"));
|
||||
assertEquals("allow_always", apiClient.lastPayload.optString("conflictDecision"));
|
||||
}
|
||||
|
||||
private static boolean viewTreeContainsText(View root, String expectedText) {
|
||||
if (root instanceof TextView) {
|
||||
CharSequence text = ((TextView) root).getText();
|
||||
@@ -56,6 +205,49 @@ public class DeviceDetailActivityTest {
|
||||
return false;
|
||||
}
|
||||
|
||||
private static JSONObject buildDevicePayload() throws Exception {
|
||||
return new JSONObject()
|
||||
.put("workspace", new JSONObject()
|
||||
.put("selectedDevice", new JSONObject()
|
||||
.put("id", "device-1")
|
||||
.put("name", "Mac Studio")
|
||||
.put("avatar", "M")
|
||||
.put("account", "17600003315")
|
||||
.put("status", "online")
|
||||
.put("quota5h", 75)
|
||||
.put("quota7d", 88)
|
||||
.put("capabilities", new JSONObject()
|
||||
.put("gui", new JSONObject()
|
||||
.put("connected", true)
|
||||
.put("lastSeenAt", "2026-04-06T08:50:00+08:00")
|
||||
.put("lastActiveProjectId", "master-agent"))
|
||||
.put("cli", new JSONObject()
|
||||
.put("connected", false)
|
||||
.put("lastSeenAt", "2026-04-06T08:40:00+08:00")
|
||||
.put("lastActiveProjectId", "")))
|
||||
.put("preferredExecutionMode", "gui")
|
||||
.put("projects", new JSONArray().put("Boss"))
|
||||
.put("endpoint", "mac://studio.local")
|
||||
.put("note", "测试设备")));
|
||||
}
|
||||
|
||||
private static JSONObject buildConflictPayload() throws Exception {
|
||||
JSONObject payload = buildDevicePayload();
|
||||
payload.getJSONObject("workspace").put(
|
||||
"projectExecutionPolicies",
|
||||
new JSONArray().put(
|
||||
new JSONObject()
|
||||
.put("deviceId", "device-1")
|
||||
.put("folderKey", "mac-studio:boss")
|
||||
.put("projectId", "thread-ui")
|
||||
.put("allowPolicy", "allow_once")
|
||||
.put("conflictState", "warning")
|
||||
.put("updatedAt", "2026-04-06T12:00:00.000Z")
|
||||
)
|
||||
);
|
||||
return payload;
|
||||
}
|
||||
|
||||
public static class TestDeviceDetailActivity extends DeviceDetailActivity {
|
||||
@Override
|
||||
protected void reload() {
|
||||
@@ -70,21 +262,62 @@ public class DeviceDetailActivityTest {
|
||||
throw new RuntimeException(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static JSONObject buildDevicePayload() throws Exception {
|
||||
return new JSONObject()
|
||||
.put("workspace", new JSONObject()
|
||||
.put("selectedDevice", new JSONObject()
|
||||
.put("id", "device-1")
|
||||
.put("name", "Mac Studio")
|
||||
.put("avatar", "M")
|
||||
.put("account", "17600003315")
|
||||
.put("status", "online")
|
||||
.put("quota5h", 75)
|
||||
.put("quota7d", 88)
|
||||
.put("projects", new JSONArray().put("Boss"))
|
||||
.put("endpoint", "mac://studio.local")
|
||||
.put("note", "测试设备")));
|
||||
private static final class RecordingBossApiClient extends BossApiClient {
|
||||
private int updateDeviceCalls;
|
||||
private String lastDeviceId;
|
||||
private JSONObject lastPayload;
|
||||
|
||||
RecordingBossApiClient(android.content.SharedPreferences prefs, String baseUrl) {
|
||||
super(prefs, baseUrl);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApiResponse updateDevice(String deviceId, JSONObject payload) {
|
||||
updateDeviceCalls += 1;
|
||||
lastDeviceId = deviceId;
|
||||
lastPayload = payload;
|
||||
try {
|
||||
return new ApiResponse(200, new JSONObject().put("ok", true));
|
||||
} catch (Exception error) {
|
||||
throw new RuntimeException(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static final class DirectExecutorService extends AbstractExecutorService {
|
||||
private boolean shutdown;
|
||||
|
||||
@Override
|
||||
public void shutdown() {
|
||||
shutdown = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Runnable> shutdownNow() {
|
||||
shutdown = true;
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isShutdown() {
|
||||
return shutdown;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isTerminated() {
|
||||
return shutdown;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean awaitTermination(long timeout, TimeUnit unit) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void execute(Runnable command) {
|
||||
command.run();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user