Files
boss/android/app/src/test/java/com/hyzq/boss/AiAccountsActivityTest.java

965 lines
41 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.Intent;
import android.content.SharedPreferences;
import android.os.Looper;
import android.view.View;
import android.view.ViewGroup;
import android.widget.EditText;
import android.widget.Spinner;
import android.widget.TextView;
import androidx.appcompat.app.AlertDialog;
import org.json.JSONObject;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import org.robolectric.Shadows;
import org.robolectric.shadows.ShadowActivity;
import org.robolectric.shadows.ShadowDialog;
import org.robolectric.shadows.ShadowToast;
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.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.AbstractExecutorService;
import java.util.concurrent.TimeUnit;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class AiAccountsActivityTest {
@Test
public void activeIdentityCardOffersMainAgentTestEntry() throws Exception {
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
JSONObject activeIdentity = new JSONObject()
.put("accountId", "acc-1")
.put("label", "主Agent")
.put("displayName", "ChatGPT OAuth 主链路账号")
.put("roleLabel", "主链路")
.put("providerLabel", "ChatGPT登录")
.put("statusLabel", "ready")
.put("note", "当前账号可直接生成主 Agent 回复。")
.put("canGenerate", true);
View card = ReflectionHelpers.callInstanceMethod(
activity,
"buildActiveIdentityCard",
ReflectionHelpers.ClassParameter.from(JSONObject.class, activeIdentity)
);
View testButton = findClickableViewContainingText(card, "测试主 Agent 对话");
assertNotNull(testButton);
testButton.performClick();
ShadowActivity shadowActivity = Shadows.shadowOf(activity);
Intent nextIntent = shadowActivity.getNextStartedActivity();
assertNotNull(nextIntent);
assertEquals(ProjectDetailActivity.class.getName(), nextIntent.getComponent().getClassName());
assertEquals("master-agent", nextIntent.getStringExtra(ProjectDetailActivity.EXTRA_PROJECT_ID));
}
@Test
public void renderAccountsShowsStructuredSectionsAndExpandedEntries() throws Exception {
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
JSONObject payload = new JSONObject()
.put("activeIdentity", new JSONObject()
.put("accountId", "chatgpt-primary")
.put("label", "主Agent")
.put("displayName", "ChatGPT OAuth 主链路账号")
.put("roleLabel", "主链路")
.put("providerLabel", "ChatGPT登录")
.put("statusLabel", "ready")
.put("canGenerate", true))
.put("accounts", new org.json.JSONArray()
.put(new JSONObject()
.put("accountId", "chatgpt-primary")
.put("label", "主Agent")
.put("displayName", "ChatGPT OAuth 主链路账号")
.put("roleLabel", "主链路")
.put("providerLabel", "ChatGPT登录")
.put("provider", "chatgpt_oauth")
.put("role", "primary")
.put("statusLabel", "ready")
.put("enabled", true)
.put("isActive", true))
.put(new JSONObject()
.put("accountId", "hyzq-backup")
.put("label", "备用API")
.put("displayName", "环宇智擎 备用账号")
.put("roleLabel", "备用链路")
.put("providerLabel", "环宇智擎")
.put("provider", "hyzq_api")
.put("role", "backup")
.put("statusLabel", "ready")
.put("enabled", true)
.put("isActive", false)
.put("apiKeyConfigured", true)
.put("apiBaseUrl", "https://api.hyzq2046.com/v1"))
.put(new JSONObject()
.put("accountId", "master-node")
.put("label", "主Agent")
.put("displayName", "绑定电脑上的 Codex 节点")
.put("roleLabel", "主链路")
.put("providerLabel", "主Agent 节点")
.put("provider", "master_codex_node")
.put("role", "primary")
.put("statusLabel", "ready")
.put("enabled", true)
.put("isActive", false)));
ReflectionHelpers.callInstanceMethod(
activity,
"renderAccounts",
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload)
);
View root = activity.findViewById(R.id.screen_content);
assertNotNull(root);
assertTrue(viewTreeContainsText(root, "主要API配置"));
assertTrue(viewTreeContainsText(root, "备用API配置"));
assertFalse(viewTreeContainsText(root, "OAuth 登录"));
assertFalse(viewTreeContainsText(root, "API 接入"));
assertFalse(viewTreeContainsText(root, "谷歌登录"));
assertFalse(viewTreeContainsText(root, "ChatGPT登录"));
assertFalse(viewTreeContainsText(root, "阿里"));
assertFalse(viewTreeContainsText(root, "Minimax"));
assertFalse(viewTreeContainsText(root, "GLM"));
assertFalse(viewTreeContainsText(root, "环宇智擎"));
assertFalse(viewTreeContainsText(root, "自定义"));
assertFalse(viewTreeContainsText(root, "绑定设备节点"));
}
@Test
public void tappingPrimaryConfigEntryOpensPrimaryDetailPage() throws Exception {
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderAccounts",
ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject().put("accounts", new org.json.JSONArray()))
);
View root = activity.findViewById(R.id.screen_content);
View entry = findClickableViewContainingText(root, "主要API配置");
assertNotNull(entry);
entry.performClick();
ShadowActivity shadowActivity = Shadows.shadowOf(activity);
Intent nextIntent = shadowActivity.getNextStartedActivity();
assertNotNull(nextIntent);
assertEquals(AiAccountsActivity.class.getName(), nextIntent.getComponent().getClassName());
assertEquals("primary", nextIntent.getStringExtra("ai_accounts_role"));
}
@Test
public void detailPageShowsOnlySelectedRoleConfiguration() throws Exception {
Intent intent = new Intent(
org.robolectric.RuntimeEnvironment.getApplication(),
TestAiAccountsActivity.class
);
intent.putExtra("ai_accounts_role", "primary");
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class, intent).setup().get();
JSONObject payload = new JSONObject()
.put("accounts", new org.json.JSONArray()
.put(new JSONObject()
.put("accountId", "chatgpt-primary")
.put("label", "主要API")
.put("displayName", "ChatGPT OAuth 主链路账号")
.put("roleLabel", "主链路")
.put("providerLabel", "ChatGPT登录")
.put("provider", "chatgpt_oauth")
.put("role", "primary")
.put("model", "gpt-5.4-mini")
.put("statusLabel", "ready")
.put("enabled", true)
.put("isActive", true))
.put(new JSONObject()
.put("accountId", "hyzq-primary")
.put("label", "主要API")
.put("displayName", "环宇智擎 主链路账号")
.put("roleLabel", "主链路")
.put("providerLabel", "环宇智擎")
.put("provider", "hyzq_api")
.put("role", "primary")
.put("model", "gpt-5.4")
.put("statusLabel", "ready")
.put("enabled", true)
.put("isActive", false)
.put("apiKeyConfigured", true)
.put("apiBaseUrl", "https://api.hyzq2046.com/v1")));
ReflectionHelpers.callInstanceMethod(
activity,
"renderAccounts",
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload)
);
View root = activity.findViewById(R.id.screen_content);
assertNotNull(root);
assertTrue(viewTreeContainsText(root, "当前使用方式"));
assertTrue(viewTreeContainsText(root, "主Agent模式"));
assertTrue(viewTreeContainsText(root, "快速反应模型"));
assertTrue(viewTreeContainsText(root, "深度思考模型"));
assertTrue(viewTreeContainsText(root, "ChatGPT登录"));
assertTrue(viewTreeContainsText(root, "OAuth 登录"));
assertTrue(viewTreeContainsText(root, "当前模型gpt-5.4-mini"));
assertTrue(viewTreeContainsText(root, "当前:沿用默认"));
assertTrue(viewTreeContainsText(root, "当前gpt-5.4-mini"));
assertTrue(viewTreeContainsText(root, "当前gpt-5.4"));
assertTrue(viewTreeContainsText(root, "API 接入"));
assertTrue(viewTreeContainsText(root, "已配置ChatGPT登录"));
assertTrue(viewTreeContainsText(root, "已配置:环宇智擎"));
assertFalse(viewTreeContainsText(root, "谷歌登录"));
assertFalse(viewTreeContainsText(root, "阿里"));
assertFalse(viewTreeContainsText(root, "Minimax"));
assertFalse(viewTreeContainsText(root, "GLM"));
assertFalse(viewTreeContainsText(root, "自定义"));
assertFalse(viewTreeContainsText(root, "可编辑配置"));
assertFalse(viewTreeContainsText(root, "当前已保存"));
assertFalse(viewTreeContainsText(root, "只读状态"));
assertFalse(viewTreeContainsText(root, "备用API配置"));
}
@Test
public void currentMethodEntryOpensCurrentAccountEditor() throws Exception {
Intent intent = new Intent(
org.robolectric.RuntimeEnvironment.getApplication(),
TestAiAccountsActivity.class
);
intent.putExtra("ai_accounts_role", "primary");
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class, intent).setup().get();
ReflectionHelpers.setField(activity, "currentMasterAgentModelOverride", "gpt-5.4-mini");
ReflectionHelpers.setField(activity, "currentMasterAgentReasoningEffortOverride", "low");
ReflectionHelpers.setField(activity, "currentFastModelOverride", "gpt-5.4-mini");
ReflectionHelpers.setField(activity, "currentDeepModelOverride", "gpt-5.4");
JSONObject payload = new JSONObject()
.put("accounts", new org.json.JSONArray()
.put(new JSONObject()
.put("accountId", "chatgpt-primary")
.put("label", "主要API")
.put("displayName", "ChatGPT OAuth 主链路账号")
.put("roleLabel", "主链路")
.put("providerLabel", "ChatGPT登录")
.put("provider", "chatgpt_oauth")
.put("role", "primary")
.put("model", "gpt-5.4-mini")
.put("statusLabel", "ready")
.put("enabled", true)
.put("isActive", true)));
ReflectionHelpers.callInstanceMethod(
activity,
"renderAccounts",
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload)
);
View root = activity.findViewById(R.id.screen_content);
View entry = findClickableViewContainingText(root, "当前使用方式");
assertNotNull(entry);
entry.performClick();
Shadows.shadowOf(Looper.getMainLooper()).idle();
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
assertNotNull(dialog);
View dialogRoot = dialog.getWindow().getDecorView();
assertTrue(viewTreeContainsText(dialogRoot, "账号快捷登录"));
assertTrue(viewTreeContainsText(dialogRoot, "选择模型"));
}
@Test
public void fastModeEntryOpensDedicatedModelPicker() throws Exception {
Intent intent = new Intent(
org.robolectric.RuntimeEnvironment.getApplication(),
TestAiAccountsActivity.class
);
intent.putExtra("ai_accounts_role", "primary");
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class, intent).setup().get();
ReflectionHelpers.setField(activity, "currentFastModelOverride", "gpt-4.1");
ReflectionHelpers.setField(activity, "currentDeepModelOverride", "gpt-5.4");
ReflectionHelpers.callInstanceMethod(
activity,
"renderAccounts",
ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject()
.put("accounts", new org.json.JSONArray()
.put(new JSONObject()
.put("accountId", "chatgpt-primary")
.put("label", "主要API")
.put("displayName", "ChatGPT OAuth 主链路账号")
.put("roleLabel", "主链路")
.put("providerLabel", "ChatGPT登录")
.put("provider", "chatgpt_oauth")
.put("role", "primary")
.put("model", "gpt-5.4-mini")
.put("statusLabel", "ready")
.put("enabled", true)
.put("isActive", true))))
);
View root = activity.findViewById(R.id.screen_content);
View entry = findClickableViewContainingText(root, "快速反应模型");
assertNotNull(entry);
entry.performClick();
Shadows.shadowOf(Looper.getMainLooper()).idle();
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
assertNotNull(dialog);
View dialogRoot = dialog.getWindow().getDecorView();
assertTrue(viewTreeContainsText(dialogRoot, "快速反应模型"));
assertTrue(viewTreeContainsText(dialogRoot, "gpt-4.1"));
}
@Test
public void tappingOauthEntryShowsOauthProviderChooser() throws Exception {
Intent intent = new Intent(
org.robolectric.RuntimeEnvironment.getApplication(),
TestAiAccountsActivity.class
);
intent.putExtra("ai_accounts_role", "primary");
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class, intent).setup().get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderAccounts",
ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject().put("accounts", new org.json.JSONArray()))
);
View root = activity.findViewById(R.id.screen_content);
View entry = findClickableViewContainingText(root, "OAuth 登录");
assertNotNull(entry);
entry.performClick();
Shadows.shadowOf(Looper.getMainLooper()).idle();
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
assertNotNull(dialog);
View dialogRoot = dialog.getWindow().getDecorView();
assertTrue(viewTreeContainsText(dialogRoot, "谷歌登录"));
assertTrue(viewTreeContainsText(dialogRoot, "ChatGPT登录"));
}
@Test
public void tappingApiEntryShowsApiProviderChooser() throws Exception {
Intent intent = new Intent(
org.robolectric.RuntimeEnvironment.getApplication(),
TestAiAccountsActivity.class
);
intent.putExtra("ai_accounts_role", "primary");
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class, intent).setup().get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderAccounts",
ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject().put("accounts", new org.json.JSONArray()))
);
View root = activity.findViewById(R.id.screen_content);
View entry = findClickableViewContainingText(root, "API 接入");
assertNotNull(entry);
entry.performClick();
Shadows.shadowOf(Looper.getMainLooper()).idle();
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
assertNotNull(dialog);
View dialogRoot = dialog.getWindow().getDecorView();
assertTrue(viewTreeContainsText(dialogRoot, "阿里"));
assertTrue(viewTreeContainsText(dialogRoot, "Minimax"));
assertTrue(viewTreeContainsText(dialogRoot, "GLM"));
assertTrue(viewTreeContainsText(dialogRoot, "环宇智擎"));
assertTrue(viewTreeContainsText(dialogRoot, "自定义"));
}
@Test
public void defaultApiBaseUrlForProviderSupportsExpandedApiProviders() {
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
String openai = ReflectionHelpers.callInstanceMethod(
activity,
"defaultApiBaseUrlForProvider",
ReflectionHelpers.ClassParameter.from(String.class, "openai_api")
);
String aliyun = ReflectionHelpers.callInstanceMethod(
activity,
"defaultApiBaseUrlForProvider",
ReflectionHelpers.ClassParameter.from(String.class, "aliyun_qwen_api")
);
String minimax = ReflectionHelpers.callInstanceMethod(
activity,
"defaultApiBaseUrlForProvider",
ReflectionHelpers.ClassParameter.from(String.class, "minimax_api")
);
String glm = ReflectionHelpers.callInstanceMethod(
activity,
"defaultApiBaseUrlForProvider",
ReflectionHelpers.ClassParameter.from(String.class, "glm_api")
);
String hyzq = ReflectionHelpers.callInstanceMethod(
activity,
"defaultApiBaseUrlForProvider",
ReflectionHelpers.ClassParameter.from(String.class, "hyzq_api")
);
String custom = ReflectionHelpers.callInstanceMethod(
activity,
"defaultApiBaseUrlForProvider",
ReflectionHelpers.ClassParameter.from(String.class, "custom_api")
);
assertEquals("https://api.openai.com/v1", openai);
assertEquals("https://dashscope.aliyuncs.com/compatible-mode/v1", aliyun);
assertEquals("https://api.minimaxi.com/v1", minimax);
assertEquals("https://open.bigmodel.cn/api/paas/v4", glm);
assertEquals("https://api.hyzq2046.com/v1", hyzq);
assertEquals("", custom);
}
@Test
public void openOauthAccountDialogShowsLoginAction() throws Exception {
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
ReflectionHelpers.callInstanceMethod(
activity,
"openOauthAccountDialog",
ReflectionHelpers.ClassParameter.from(String.class, "primary"),
ReflectionHelpers.ClassParameter.from(String.class, "google_oauth"),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
);
Shadows.shadowOf(Looper.getMainLooper()).idle();
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
assertNotNull(dialog);
View root = dialog.getWindow().getDecorView();
assertTrue(viewTreeContainsText(root, "账号快捷登录"));
assertTrue(viewTreeContainsText(root, "谷歌登录"));
Spinner modelSpinner = findSpinner(root);
assertNotNull(modelSpinner);
assertFalse(modelSpinner.isEnabled());
assertFalse(modelSpinner.isClickable());
}
@Test
public void openOauthAccountDialogEnablesModelSelectionWhenAccountIsReady() throws Exception {
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
JSONObject existing = new JSONObject()
.put("label", "主要API")
.put("displayName", "ChatGPT OAuth 主链路账号")
.put("accountIdentifier", "kris@example.com")
.put("model", "gpt-5.4")
.put("loginStatusNote", "已登录")
.put("enabled", true)
.put("isActive", true)
.put("status", "ready")
.put("statusLabel", "ready");
ReflectionHelpers.callInstanceMethod(
activity,
"openOauthAccountDialog",
ReflectionHelpers.ClassParameter.from(String.class, "primary"),
ReflectionHelpers.ClassParameter.from(String.class, "chatgpt_oauth"),
ReflectionHelpers.ClassParameter.from(JSONObject.class, existing)
);
Shadows.shadowOf(Looper.getMainLooper()).idle();
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
assertNotNull(dialog);
View root = dialog.getWindow().getDecorView();
Spinner modelSpinner = findSpinner(root);
assertNotNull(modelSpinner);
assertTrue(modelSpinner.isEnabled());
assertTrue(modelSpinner.isClickable());
}
@Test
public void openApiAccountDialogLocksModelSelectionBeforeValidation() throws Exception {
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
ReflectionHelpers.callInstanceMethod(
activity,
"openApiAccountDialog",
ReflectionHelpers.ClassParameter.from(String.class, "backup"),
ReflectionHelpers.ClassParameter.from(String.class, "hyzq_api"),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null),
ReflectionHelpers.ClassParameter.from(String.class, null)
);
Shadows.shadowOf(Looper.getMainLooper()).idle();
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
assertNotNull(dialog);
View root = dialog.getWindow().getDecorView();
assertNotNull(findEditTextWithHint(root, "账号标识 / 备注"));
assertNotNull(findEditTextWithHint(root, "API Key"));
Spinner modelSpinner = findSpinner(root);
assertNotNull(modelSpinner);
assertFalse(modelSpinner.isEnabled());
assertEquals(0, ((android.widget.ArrayAdapter<?>) modelSpinner.getAdapter()).getCount());
}
@Test
public void applyDraftValidatedModelsEnablesModelSelection() throws Exception {
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
Spinner spinner = new Spinner(activity);
android.widget.ArrayAdapter<String> adapter = new android.widget.ArrayAdapter<>(
activity,
android.R.layout.simple_spinner_dropdown_item,
new ArrayList<>()
);
spinner.setAdapter(adapter);
spinner.setEnabled(false);
org.json.JSONArray models = new org.json.JSONArray().put("gpt-5.4-mini").put("gpt-5.4");
ReflectionHelpers.callInstanceMethod(
activity,
"applyValidatedApiModels",
ReflectionHelpers.ClassParameter.from(Spinner.class, spinner),
ReflectionHelpers.ClassParameter.from(android.widget.ArrayAdapter.class, adapter),
ReflectionHelpers.ClassParameter.from(org.json.JSONArray.class, models),
ReflectionHelpers.ClassParameter.from(String.class, "gpt-5.4")
);
assertTrue(spinner.isEnabled());
assertEquals(2, adapter.getCount());
assertEquals("gpt-5.4", spinner.getSelectedItem());
}
@Test
public void saveExpandedApiProviderUsesGenericCreateFlowAndAutoFillsBaseUrl() throws Exception {
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
RecordingConnection createConnection = new RecordingConnection(
new URL("https://boss.hyzq.net/api/v1/accounts"),
200,
"{\"ok\":true,\"accountId\":\"acc-1\"}",
"{\"ok\":false,\"message\":\"SAVE_FAILED\"}"
);
ReflectionHelpers.setField(activity, "apiClient", new ScriptedBossApiClient(createConnection));
ReflectionHelpers.setField(activity, "executor", new DirectExecutorService());
int initialReloadCount = activity.reloadCount;
ReflectionHelpers.callInstanceMethod(
activity,
"saveAccount",
ReflectionHelpers.ClassParameter.from(JSONObject.class, null),
ReflectionHelpers.ClassParameter.from(String.class, "备用API"),
ReflectionHelpers.ClassParameter.from(String.class, "环宇智擎备用账号"),
ReflectionHelpers.ClassParameter.from(String.class, "fallback@example.com"),
ReflectionHelpers.ClassParameter.from(String.class, ""),
ReflectionHelpers.ClassParameter.from(String.class, ""),
ReflectionHelpers.ClassParameter.from(String.class, "gpt-5.4"),
ReflectionHelpers.ClassParameter.from(String.class, ""),
ReflectionHelpers.ClassParameter.from(String.class, "hyzq-secret"),
ReflectionHelpers.ClassParameter.from(String.class, "待校验"),
ReflectionHelpers.ClassParameter.from(boolean.class, true),
ReflectionHelpers.ClassParameter.from(boolean.class, false),
ReflectionHelpers.ClassParameter.from(String.class, "backup"),
ReflectionHelpers.ClassParameter.from(String.class, "hyzq_api")
);
org.robolectric.Shadows.shadowOf(Looper.getMainLooper()).idle();
assertEquals("AI 账号已新增", ShadowToast.getTextOfLatestToast());
assertEquals(initialReloadCount + 1, activity.reloadCount);
JSONObject requestJson = new JSONObject(createConnection.getCapturedRequestBody());
assertEquals("hyzq_api", requestJson.getString("provider"));
assertEquals("backup", requestJson.getString("role"));
assertEquals("https://api.hyzq2046.com/v1", requestJson.getString("apiBaseUrl"));
assertEquals("hyzq-secret", requestJson.getString("apiKey"));
}
@Test
public void saveOauthAccountUsesGenericCreateFlow() throws Exception {
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
RecordingConnection createConnection = new RecordingConnection(
new URL("https://boss.hyzq.net/api/v1/accounts"),
200,
"{\"ok\":true,\"accountId\":\"acc-2\"}",
"{\"ok\":false,\"message\":\"SAVE_FAILED\"}"
);
ReflectionHelpers.setField(activity, "apiClient", new ScriptedBossApiClient(createConnection));
ReflectionHelpers.setField(activity, "executor", new DirectExecutorService());
ReflectionHelpers.callInstanceMethod(
activity,
"saveAccount",
ReflectionHelpers.ClassParameter.from(JSONObject.class, null),
ReflectionHelpers.ClassParameter.from(String.class, "主Agent"),
ReflectionHelpers.ClassParameter.from(String.class, "ChatGPT OAuth 主链路账号"),
ReflectionHelpers.ClassParameter.from(String.class, "kris@example.com"),
ReflectionHelpers.ClassParameter.from(String.class, ""),
ReflectionHelpers.ClassParameter.from(String.class, ""),
ReflectionHelpers.ClassParameter.from(String.class, "gpt-5.4"),
ReflectionHelpers.ClassParameter.from(String.class, ""),
ReflectionHelpers.ClassParameter.from(String.class, ""),
ReflectionHelpers.ClassParameter.from(String.class, "待网页登录"),
ReflectionHelpers.ClassParameter.from(boolean.class, true),
ReflectionHelpers.ClassParameter.from(boolean.class, true),
ReflectionHelpers.ClassParameter.from(String.class, "primary"),
ReflectionHelpers.ClassParameter.from(String.class, "chatgpt_oauth")
);
org.robolectric.Shadows.shadowOf(Looper.getMainLooper()).idle();
assertEquals("AI 账号已新增", ShadowToast.getTextOfLatestToast());
JSONObject requestJson = new JSONObject(createConnection.getCapturedRequestBody());
assertEquals("chatgpt_oauth", requestJson.getString("provider"));
assertEquals("primary", requestJson.getString("role"));
assertEquals("待网页登录", requestJson.getString("loginStatusNote"));
assertEquals("", requestJson.getString("apiBaseUrl"));
}
private static final class TestAiAccountsActivity extends AiAccountsActivity {
private int reloadCount = 0;
@Override
protected void reload() {
reloadCount += 1;
}
}
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();
}
}
private static final class ScriptedBossApiClient extends BossApiClient {
private final Map<String, RecordingConnection> connections;
ScriptedBossApiClient(RecordingConnection... connections) {
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
this.connections = new HashMap<>();
for (RecordingConnection connection : connections) {
this.connections.put(connection.getURL().getPath(), connection);
}
}
@Override
HttpURLConnection openConnection(String path) {
RecordingConnection connection = connections.get(path);
if (connection == null) {
throw new IllegalStateException("Missing scripted connection for " + path);
}
return connection;
}
@Override
String encode(String value) {
return value;
}
@Override
void rememberIdentity(JSONObject json) {
// JVM 单测不需要落 Android 侧身份缓存。
}
}
private static final class RecordingConnection extends HttpURLConnection {
private final ByteArrayOutputStream requestBody = new ByteArrayOutputStream();
private final Map<String, String> requestHeaders = new HashMap<>();
private final int responseCodeValue;
private final String responseBody;
private final String errorBody;
RecordingConnection(URL url, int responseCodeValue, String responseBody, String errorBody) {
super(url);
this.responseCodeValue = responseCodeValue;
this.responseBody = responseBody;
this.errorBody = errorBody;
}
@Override
public void disconnect() {}
@Override
public boolean usingProxy() {
return false;
}
@Override
public void connect() {}
@Override
public void setRequestMethod(String method) throws ProtocolException {}
@Override
public void setRequestProperty(String key, String value) {
requestHeaders.put(key, value);
}
@Override
public OutputStream getOutputStream() {
return requestBody;
}
@Override
public int getResponseCode() {
return responseCodeValue;
}
@Override
public InputStream getInputStream() {
return new ByteArrayInputStream(responseBody.getBytes(StandardCharsets.UTF_8));
}
@Override
public InputStream getErrorStream() {
return new ByteArrayInputStream(errorBody.getBytes(StandardCharsets.UTF_8));
}
@Override
public Map<String, List<String>> getHeaderFields() {
return Collections.emptyMap();
}
String getCapturedRequestBody() {
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 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 boolean commit() {
return true;
}
@Override
public void apply() {}
};
}
@Override
public void registerOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {}
@Override
public void unregisterOnSharedPreferenceChangeListener(OnSharedPreferenceChangeListener listener) {}
}
private static View findClickableViewContainingText(View root, String expectedText) {
if (root == null) {
return null;
}
if (root.isClickable() && viewTreeContainsText(root, expectedText)) {
return root;
}
if (!(root instanceof ViewGroup)) {
return null;
}
ViewGroup group = (ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
View match = findClickableViewContainingText(group.getChildAt(index), expectedText);
if (match != null) {
return match;
}
}
return null;
}
private static boolean viewTreeContainsText(View root, String expectedText) {
if (root instanceof TextView) {
CharSequence text = ((TextView) root).getText();
if (text != null && text.toString().contains(expectedText)) {
return true;
}
}
if (!(root instanceof ViewGroup)) {
return false;
}
ViewGroup group = (ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
if (viewTreeContainsText(group.getChildAt(index), expectedText)) {
return true;
}
}
return false;
}
private static EditText findEditTextWithHint(View root, String expectedText) {
if (root instanceof EditText) {
CharSequence hint = ((EditText) root).getHint();
if (hint != null && hint.toString().contains(expectedText)) {
return (EditText) root;
}
}
if (!(root instanceof ViewGroup)) {
return null;
}
ViewGroup group = (ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
EditText match = findEditTextWithHint(group.getChildAt(index), expectedText);
if (match != null) {
return match;
}
}
return null;
}
private static EditText findEditTextWithText(View root, String expectedText) {
if (root instanceof EditText) {
CharSequence text = ((EditText) root).getText();
if (text != null && text.toString().contains(expectedText)) {
return (EditText) root;
}
}
if (!(root instanceof ViewGroup)) {
return null;
}
ViewGroup group = (ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
EditText match = findEditTextWithText(group.getChildAt(index), expectedText);
if (match != null) {
return match;
}
}
return null;
}
private static Spinner findSpinner(View root) {
if (root instanceof Spinner) {
return (Spinner) root;
}
if (!(root instanceof ViewGroup)) {
return null;
}
ViewGroup group = (ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
Spinner match = findSpinner(group.getChildAt(index));
if (match != null) {
return match;
}
}
return null;
}
}