feat: add omx orchestration backend selection
This commit is contained in:
@@ -36,8 +36,8 @@ android {
|
||||
applicationId "com.hyzq.boss"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 21
|
||||
versionName "2.5.8"
|
||||
versionCode 22
|
||||
versionName "2.5.9"
|
||||
buildConfigField "String", "BOSS_API_BASE_URL", "\"https://boss.hyzq.net\""
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
}
|
||||
|
||||
@@ -126,6 +126,16 @@ public class BossApiClient {
|
||||
return requestWithRestore("POST", "/api/v1/projects/" + encode(projectId) + "/agent-controls", payload);
|
||||
}
|
||||
|
||||
public ApiResponse getProjectOrchestrationBackend(String projectId) throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/orchestration-backend", null);
|
||||
}
|
||||
|
||||
public ApiResponse updateProjectOrchestrationBackend(String projectId, @Nullable String requestedBackendId) throws IOException, JSONException {
|
||||
JSONObject payload = new JSONObject();
|
||||
payload.put("requestedBackendId", requestedBackendId == null ? JSONObject.NULL : requestedBackendId);
|
||||
return requestWithRestore("PATCH", "/api/v1/projects/" + encode(projectId) + "/orchestration-backend", payload);
|
||||
}
|
||||
|
||||
public ApiResponse getMasterAgentPromptProfile(String projectId) throws IOException, JSONException {
|
||||
return requestWithRestore("GET", "/api/v1/projects/" + encode(projectId) + "/prompt-profile", null);
|
||||
}
|
||||
|
||||
@@ -48,7 +48,11 @@ public class GroupInfoActivity extends BossScreenActivity {
|
||||
if (!detailResponse.ok()) throw new IllegalStateException(detailResponse.message());
|
||||
BossApiClient.ApiResponse participantsResponse = apiClient.getConversationParticipants(projectId);
|
||||
if (!participantsResponse.ok()) throw new IllegalStateException(participantsResponse.message());
|
||||
runOnUiThread(() -> renderGroup(detailResponse.json, participantsResponse.json));
|
||||
BossApiClient.ApiResponse orchestrationResponse = apiClient.getProjectOrchestrationBackend(projectId);
|
||||
JSONObject orchestrationBackend = orchestrationResponse.ok()
|
||||
? orchestrationResponse.json
|
||||
: buildFallbackOrchestrationBackendPayload(orchestrationResponse.message());
|
||||
runOnUiThread(() -> renderGroup(detailResponse.json, participantsResponse.json, orchestrationBackend));
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
@@ -59,6 +63,10 @@ public class GroupInfoActivity extends BossScreenActivity {
|
||||
}
|
||||
|
||||
private void renderGroup(JSONObject detail, JSONObject participantsPayload) {
|
||||
renderGroup(detail, participantsPayload, null);
|
||||
}
|
||||
|
||||
private void renderGroup(JSONObject detail, JSONObject participantsPayload, @Nullable JSONObject orchestrationBackendPayload) {
|
||||
replaceContent();
|
||||
JSONObject project = detail.optJSONObject("project");
|
||||
JSONArray participants = participantsPayload.optJSONArray("participants");
|
||||
@@ -94,6 +102,9 @@ public class GroupInfoActivity extends BossScreenActivity {
|
||||
null,
|
||||
v -> openProject(projectId, projectName)
|
||||
));
|
||||
if (orchestrationBackendPayload != null) {
|
||||
appendContent(buildOrchestrationBackendRow(orchestrationBackendPayload));
|
||||
}
|
||||
|
||||
if (repairRequired) {
|
||||
String meta = invalidParticipantCount > 0
|
||||
@@ -316,6 +327,171 @@ public class GroupInfoActivity extends BossScreenActivity {
|
||||
});
|
||||
}
|
||||
|
||||
private LinearLayout buildOrchestrationBackendRow(JSONObject backendPayload) {
|
||||
String requestedBackendId = backendPayload.optString("requestedBackendId", "boss-native-orchestrator");
|
||||
String currentBackendId = backendPayload.optString("currentBackendId", requestedBackendId);
|
||||
JSONObject omxAvailability = backendPayload.optJSONObject("omxAvailability");
|
||||
String currentLabel = resolveBackendLabel(backendPayload, currentBackendId);
|
||||
String requestedLabel = resolveBackendLabel(backendPayload, requestedBackendId);
|
||||
String subtitle = "当前:" + currentLabel;
|
||||
if (!TextUtils.equals(currentBackendId, requestedBackendId)) {
|
||||
subtitle += " · 请求:" + requestedLabel;
|
||||
}
|
||||
boolean omxSelectable = omxAvailability != null && omxAvailability.optBoolean("selectable", false);
|
||||
boolean fallbackActive = !TextUtils.equals(currentBackendId, requestedBackendId);
|
||||
if (omxAvailability != null) {
|
||||
subtitle += omxSelectable ? " · OMX 可用" : " · OMX 受限";
|
||||
}
|
||||
String meta = omxAvailability == null
|
||||
? "等待后端状态"
|
||||
: buildOrchestrationBackendAvailabilitySummary(omxAvailability, fallbackActive);
|
||||
String badge = fallbackActive ? "回退" : (omxSelectable ? "当前" : "受限");
|
||||
return BossUi.buildWechatMenuRow(
|
||||
this,
|
||||
"编排后端",
|
||||
subtitle,
|
||||
meta,
|
||||
badge,
|
||||
v -> openOrchestrationBackendDialog(backendPayload)
|
||||
);
|
||||
}
|
||||
|
||||
private void openOrchestrationBackendDialog(JSONObject backendPayload) {
|
||||
JSONArray availableChoices = backendPayload.optJSONArray("availableChoices");
|
||||
if (availableChoices == null || availableChoices.length() == 0) {
|
||||
showMessage("编排后端状态暂不可用");
|
||||
return;
|
||||
}
|
||||
|
||||
CharSequence[] items = new CharSequence[availableChoices.length()];
|
||||
final String[] backendIds = new String[availableChoices.length()];
|
||||
final boolean[] selectable = new boolean[availableChoices.length()];
|
||||
final String omxReason = backendPayload.optJSONObject("omxAvailability") == null
|
||||
? "OMX Team Runtime 当前不可用。"
|
||||
: backendPayload.optJSONObject("omxAvailability").optString("reasonLabel", "OMX Team Runtime 当前不可用。");
|
||||
final boolean omxSelectable = backendPayload.optJSONObject("omxAvailability") != null
|
||||
&& backendPayload.optJSONObject("omxAvailability").optBoolean("selectable", false);
|
||||
for (int i = 0; i < availableChoices.length(); i++) {
|
||||
JSONObject choice = availableChoices.optJSONObject(i);
|
||||
if (choice == null) {
|
||||
items[i] = "未命名后端";
|
||||
backendIds[i] = "";
|
||||
selectable[i] = false;
|
||||
continue;
|
||||
}
|
||||
backendIds[i] = choice.optString("backendId", "");
|
||||
selectable[i] = choice.optBoolean("selectable", false);
|
||||
String label = resolveBackendLabel(backendPayload, backendIds[i]);
|
||||
items[i] = label + (selectable[i] ? "" : "(不可用)");
|
||||
}
|
||||
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("选择编排后端")
|
||||
.setMessage(omxSelectable
|
||||
? "Boss Native Orchestrator 永远可用;OMX Team Runtime 当前可直接切换。"
|
||||
: "Boss Native Orchestrator 永远可用;OMX Team Runtime 当前不可用,切换时会自动回退到 Boss Native Orchestrator。")
|
||||
.setItems(items, (dialog, which) -> {
|
||||
String selectedBackendId = backendIds[which];
|
||||
if (TextUtils.isEmpty(selectedBackendId)) {
|
||||
showMessage("编排后端选择无效");
|
||||
return;
|
||||
}
|
||||
if (!selectable[which] && TextUtils.equals(selectedBackendId, "omx-team")) {
|
||||
showMessage(omxReason);
|
||||
return;
|
||||
}
|
||||
saveOrchestrationBackend(selectedBackendId);
|
||||
})
|
||||
.setNegativeButton("取消", null)
|
||||
.show();
|
||||
}
|
||||
|
||||
private void saveOrchestrationBackend(String requestedBackendId) {
|
||||
setRefreshing(true);
|
||||
executor.execute(() -> {
|
||||
try {
|
||||
BossApiClient.ApiResponse response = apiClient.updateProjectOrchestrationBackend(projectId, requestedBackendId);
|
||||
if (!response.ok()) throw new IllegalStateException(response.message());
|
||||
runOnUiThread(() -> {
|
||||
showMessage("编排后端已更新为 " + resolveBackendLabelForId(requestedBackendId));
|
||||
reload();
|
||||
});
|
||||
} catch (Exception error) {
|
||||
runOnUiThread(() -> {
|
||||
setRefreshing(false);
|
||||
showMessage("保存失败:" + error.getMessage());
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private String resolveBackendLabel(JSONObject backendPayload, String backendId) {
|
||||
JSONArray availableChoices = backendPayload.optJSONArray("availableChoices");
|
||||
if (availableChoices != null) {
|
||||
for (int i = 0; i < availableChoices.length(); i++) {
|
||||
JSONObject choice = availableChoices.optJSONObject(i);
|
||||
if (choice == null) continue;
|
||||
if (TextUtils.equals(choice.optString("backendId", ""), backendId)) {
|
||||
return choice.optString("label", resolveBackendLabelForId(backendId));
|
||||
}
|
||||
}
|
||||
}
|
||||
return resolveBackendLabelForId(backendId);
|
||||
}
|
||||
|
||||
private String resolveBackendLabelForId(String backendId) {
|
||||
if (TextUtils.equals(backendId, "omx-team")) {
|
||||
return "OMX Team Runtime";
|
||||
}
|
||||
return "Boss Native Orchestrator";
|
||||
}
|
||||
|
||||
private String normalizeOrchestrationReasonLabel(String value) {
|
||||
String trimmed = value == null ? "" : value.trim();
|
||||
if (trimmed.endsWith("。") || trimmed.endsWith(".")) {
|
||||
return trimmed.substring(0, trimmed.length() - 1);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private String buildOrchestrationBackendAvailabilitySummary(JSONObject omxAvailability, boolean fallbackActive) {
|
||||
if (omxAvailability.optBoolean("selectable", false)) {
|
||||
return "OMX Team Runtime 当前可用,当前可切换到该后端。";
|
||||
}
|
||||
String reasonLabel = normalizeOrchestrationReasonLabel(
|
||||
omxAvailability.optString("reasonLabel", "OMX Team Runtime 当前不可用。")
|
||||
);
|
||||
return fallbackActive
|
||||
? reasonLabel + ",当前已自动回退到 Boss Native Orchestrator。"
|
||||
: reasonLabel + ",切换后会自动回退到 Boss Native Orchestrator。";
|
||||
}
|
||||
|
||||
private JSONObject buildFallbackOrchestrationBackendPayload(String reason) {
|
||||
try {
|
||||
JSONArray availableChoices = new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("backendId", "boss-native-orchestrator")
|
||||
.put("label", "Boss Native Orchestrator")
|
||||
.put("selectable", true)
|
||||
.put("current", true))
|
||||
.put(new JSONObject()
|
||||
.put("backendId", "omx-team")
|
||||
.put("label", "OMX Team Runtime")
|
||||
.put("selectable", false)
|
||||
.put("current", false));
|
||||
return new JSONObject()
|
||||
.put("currentBackendId", "boss-native-orchestrator")
|
||||
.put("requestedBackendId", "boss-native-orchestrator")
|
||||
.put("availableChoices", availableChoices)
|
||||
.put("omxAvailability", new JSONObject()
|
||||
.put("selectable", false)
|
||||
.put("reason", "disabled")
|
||||
.put("reasonLabel", TextUtils.isEmpty(reason) ? "OMX Team Runtime 当前不可用。" : reason));
|
||||
} catch (Exception error) {
|
||||
return new JSONObject();
|
||||
}
|
||||
}
|
||||
|
||||
private String buildSubtitle(String folderName, int count) {
|
||||
String memberLabel = count <= 0 ? "暂无成员" : count + " 个成员";
|
||||
if (folderName.isEmpty()) {
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
package com.hyzq.boss;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
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 BossApiClientOrchestrationBackendTest {
|
||||
@Test
|
||||
public void getProjectOrchestrationBackendUsesScopedEndpoint() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(
|
||||
new URL("https://boss.hyzq.net/api/v1/projects/audit-collab/orchestration-backend")
|
||||
);
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.getProjectOrchestrationBackend("audit-collab");
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/projects/audit-collab/orchestration-backend", apiClient.lastPath);
|
||||
assertEquals("GET", connection.requestMethodValue);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void updateProjectOrchestrationBackendWritesRequestedBackendId() throws Exception {
|
||||
RecordingConnection connection = new RecordingConnection(
|
||||
new URL("https://boss.hyzq.net/api/v1/projects/audit-collab/orchestration-backend")
|
||||
);
|
||||
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
|
||||
|
||||
BossApiClient.ApiResponse response = apiClient.updateProjectOrchestrationBackend("audit-collab", "omx-team");
|
||||
|
||||
assertEquals(200, response.statusCode);
|
||||
assertEquals("/api/v1/projects/audit-collab/orchestration-backend", apiClient.lastPath);
|
||||
assertEquals("PATCH", connection.requestMethodValue);
|
||||
assertEquals("{\"requestedBackendId\":\"omx-team\"}", 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) {
|
||||
// No-op for JVM tests.
|
||||
}
|
||||
}
|
||||
|
||||
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) {}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import static org.junit.Assert.assertNotNull;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import android.content.Intent;
|
||||
import android.os.Looper;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
import android.widget.LinearLayout;
|
||||
@@ -21,6 +22,22 @@ import org.robolectric.Shadows;
|
||||
import org.robolectric.annotation.Config;
|
||||
import org.robolectric.util.ReflectionHelpers;
|
||||
|
||||
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;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.AbstractExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@RunWith(RobolectricTestRunner.class)
|
||||
@Config(sdk = 34)
|
||||
public class GroupInfoActivityTest {
|
||||
@@ -115,6 +132,80 @@ public class GroupInfoActivityTest {
|
||||
assertTrue(viewTreeContainsText(content, "失效"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void renderGroupShowsOrchestrationBackendStateAndFallbackReason() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(GroupInfoActivity.EXTRA_PROJECT_ID, "group-1")
|
||||
.putExtra(GroupInfoActivity.EXTRA_PROJECT_NAME, "巡检协作群");
|
||||
TestGroupInfoActivity activity = Robolectric
|
||||
.buildActivity(TestGroupInfoActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderGroup",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildDetailPayload()),
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildParticipantsPayload()),
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildOrchestrationBackendPayload())
|
||||
);
|
||||
|
||||
LinearLayout content = activity.findViewById(R.id.screen_content);
|
||||
assertTrue(viewTreeContainsText(content, "编排后端"));
|
||||
assertTrue(viewTreeContainsText(content, "当前:Boss Native Orchestrator · OMX 可用"));
|
||||
assertTrue(viewTreeContainsText(content, "OMX Team Runtime 当前可用,当前可切换到该后端。"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void renderGroupShowsOmxFallbackHintWhenOmxRuntimeIsUnavailable() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(GroupInfoActivity.EXTRA_PROJECT_ID, "group-1")
|
||||
.putExtra(GroupInfoActivity.EXTRA_PROJECT_NAME, "巡检协作群");
|
||||
TestGroupInfoActivity activity = Robolectric
|
||||
.buildActivity(TestGroupInfoActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"renderGroup",
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildDetailPayload()),
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildParticipantsPayload()),
|
||||
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildUnavailableOrchestrationBackendPayload())
|
||||
);
|
||||
|
||||
LinearLayout content = activity.findViewById(R.id.screen_content);
|
||||
assertTrue(viewTreeContainsText(content, "当前:Boss Native Orchestrator · 请求:OMX Team Runtime · OMX 受限"));
|
||||
assertTrue(viewTreeContainsText(content, "OMX Team Runtime 当前不可用,当前已自动回退到 Boss Native Orchestrator。"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void saveOrchestrationBackendUsesScopedEndpoint() throws Exception {
|
||||
Intent intent = new Intent()
|
||||
.putExtra(GroupInfoActivity.EXTRA_PROJECT_ID, "group-1")
|
||||
.putExtra(GroupInfoActivity.EXTRA_PROJECT_NAME, "巡检协作群");
|
||||
TestGroupInfoActivity activity = Robolectric
|
||||
.buildActivity(TestGroupInfoActivity.class, intent)
|
||||
.setup()
|
||||
.get();
|
||||
RecordingConnection connection = new RecordingConnection(
|
||||
new URL("https://boss.hyzq.net/api/v1/projects/group-1/orchestration-backend")
|
||||
);
|
||||
ReflectionHelpers.setField(activity, "apiClient", new RecordingBossApiClient(connection));
|
||||
ReflectionHelpers.setField(activity, "executor", new DirectExecutorService());
|
||||
|
||||
ReflectionHelpers.callInstanceMethod(
|
||||
activity,
|
||||
"saveOrchestrationBackend",
|
||||
ReflectionHelpers.ClassParameter.from(String.class, "omx-team")
|
||||
);
|
||||
Shadows.shadowOf(Looper.getMainLooper()).idle();
|
||||
|
||||
assertEquals("/api/v1/projects/group-1/orchestration-backend", connection.lastPath);
|
||||
assertEquals("PATCH", connection.requestMethodValue);
|
||||
assertEquals("{\"requestedBackendId\":\"omx-team\"}", connection.requestBody());
|
||||
}
|
||||
|
||||
private static JSONObject buildDetailPayload() throws Exception {
|
||||
JSONObject threadMeta = new JSONObject()
|
||||
.put("threadId", "group-thread-3")
|
||||
@@ -165,6 +256,50 @@ public class GroupInfoActivityTest {
|
||||
.put("invalidParticipantCount", 1);
|
||||
}
|
||||
|
||||
private static JSONObject buildOrchestrationBackendPayload() throws Exception {
|
||||
JSONArray availableChoices = new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("backendId", "boss-native-orchestrator")
|
||||
.put("label", "Boss Native Orchestrator")
|
||||
.put("selectable", true)
|
||||
.put("current", true))
|
||||
.put(new JSONObject()
|
||||
.put("backendId", "omx-team")
|
||||
.put("label", "OMX Team Runtime")
|
||||
.put("selectable", true)
|
||||
.put("current", false));
|
||||
return new JSONObject()
|
||||
.put("currentBackendId", "boss-native-orchestrator")
|
||||
.put("requestedBackendId", "boss-native-orchestrator")
|
||||
.put("availableChoices", availableChoices)
|
||||
.put("omxAvailability", new JSONObject()
|
||||
.put("selectable", true)
|
||||
.put("reason", "ready")
|
||||
.put("reasonLabel", "OMX Team Runtime 可用。"));
|
||||
}
|
||||
|
||||
private static JSONObject buildUnavailableOrchestrationBackendPayload() throws Exception {
|
||||
JSONArray availableChoices = new JSONArray()
|
||||
.put(new JSONObject()
|
||||
.put("backendId", "boss-native-orchestrator")
|
||||
.put("label", "Boss Native Orchestrator")
|
||||
.put("selectable", true)
|
||||
.put("current", true))
|
||||
.put(new JSONObject()
|
||||
.put("backendId", "omx-team")
|
||||
.put("label", "OMX Team Runtime")
|
||||
.put("selectable", false)
|
||||
.put("current", false));
|
||||
return new JSONObject()
|
||||
.put("currentBackendId", "boss-native-orchestrator")
|
||||
.put("requestedBackendId", "omx-team")
|
||||
.put("availableChoices", availableChoices)
|
||||
.put("omxAvailability", new JSONObject()
|
||||
.put("selectable", false)
|
||||
.put("reason", "script_not_found")
|
||||
.put("reasonLabel", "OMX Team Runtime 当前不可用。"));
|
||||
}
|
||||
|
||||
private static boolean viewTreeContainsText(View root, String expectedText) {
|
||||
if (root instanceof TextView) {
|
||||
CharSequence text = ((TextView) root).getText();
|
||||
@@ -210,4 +345,221 @@ public class GroupInfoActivityTest {
|
||||
// Tests render the lightweight info state directly.
|
||||
}
|
||||
}
|
||||
|
||||
private static final class RecordingBossApiClient extends BossApiClient {
|
||||
private final RecordingConnection connection;
|
||||
|
||||
RecordingBossApiClient(RecordingConnection connection) {
|
||||
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
|
||||
this.connection = connection;
|
||||
}
|
||||
|
||||
@Override
|
||||
HttpURLConnection openConnection(String path) {
|
||||
connection.lastPath = path;
|
||||
return connection;
|
||||
}
|
||||
|
||||
@Override
|
||||
String encode(String value) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
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";
|
||||
private String lastPath = "";
|
||||
|
||||
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 android.content.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) {}
|
||||
}
|
||||
|
||||
private static final class DirectExecutorService extends AbstractExecutorService {
|
||||
private boolean shutdown;
|
||||
|
||||
@Override
|
||||
public void shutdown() {
|
||||
shutdown = true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Runnable> shutdownNow() {
|
||||
shutdown = true;
|
||||
return java.util.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