feat: harden ai onboarding and approval chat flows

This commit is contained in:
kris
2026-03-31 04:18:57 +08:00
parent 4336dc22a7
commit 0cb2171dd3
16 changed files with 551 additions and 27 deletions

View File

@@ -0,0 +1,345 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import android.content.SharedPreferences;
import android.os.Looper;
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.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.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 submitOpenAiOnboarding_reportsExplicitPrimaryControllerSuccessAndRefreshesSummary() throws Exception {
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
ReflectionHelpers.setField(activity, "apiClient", new ScriptedBossApiClient(
new RecordingConnection(
new URL("https://boss.hyzq.net/api/v1/accounts/onboard/openai-api"),
200,
"{\"ok\":true,\"accountId\":\"acc-1\"}",
"{\"ok\":false,\"message\":\"ONBOARD_FAILED\"}"
),
new RecordingConnection(
new URL("https://boss.hyzq.net/api/v1/accounts/acc-1/validate"),
200,
"{\"ok\":true,\"message\":\"校验通过\"}",
"{\"ok\":false,\"message\":\"VALIDATION_FAILED\"}"
)
));
ReflectionHelpers.setField(activity, "executor", new DirectExecutorService());
int initialReloadCount = activity.reloadCount;
ReflectionHelpers.callInstanceMethod(
activity,
"submitOpenAiOnboarding",
ReflectionHelpers.ClassParameter.from(String.class, "主 GPT"),
ReflectionHelpers.ClassParameter.from(String.class, "OpenAI 平台账号"),
ReflectionHelpers.ClassParameter.from(String.class, "sk-test"),
ReflectionHelpers.ClassParameter.from(String.class, "gpt-5.4"),
ReflectionHelpers.ClassParameter.from(String.class, "sk-test-key")
);
org.robolectric.Shadows.shadowOf(Looper.getMainLooper()).idle();
assertEquals("OpenAI 平台账号已登录,并设为当前主控。", ShadowToast.getTextOfLatestToast());
assertEquals(initialReloadCount + 1, activity.reloadCount);
}
@Test
public void submitOpenAiOnboarding_showsClearChineseFailurePrefix() throws Exception {
TestAiAccountsActivity activity = Robolectric.buildActivity(TestAiAccountsActivity.class).setup().get();
ReflectionHelpers.setField(activity, "apiClient", new ScriptedBossApiClient(
new RecordingConnection(
new URL("https://boss.hyzq.net/api/v1/accounts/onboard/openai-api"),
403,
"{\"ok\":false,\"message\":\"API Key 无效\"}",
"{\"ok\":false,\"message\":\"API Key 无效\"}"
)
));
ReflectionHelpers.setField(activity, "executor", new DirectExecutorService());
ReflectionHelpers.callInstanceMethod(
activity,
"submitOpenAiOnboarding",
ReflectionHelpers.ClassParameter.from(String.class, "主 GPT"),
ReflectionHelpers.ClassParameter.from(String.class, "OpenAI 平台账号"),
ReflectionHelpers.ClassParameter.from(String.class, "sk-test"),
ReflectionHelpers.ClassParameter.from(String.class, "gpt-5.4"),
ReflectionHelpers.ClassParameter.from(String.class, "bad-key")
);
org.robolectric.Shadows.shadowOf(Looper.getMainLooper()).idle();
assertEquals("OpenAI 平台账号登录失败API Key 无效", ShadowToast.getTextOfLatestToast());
assertEquals(1, activity.reloadCount);
}
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;
private String requestMethodValue = "GET";
private String contentTypeValue = "";
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 {
requestMethodValue = method;
}
@Override
public void setRequestProperty(String key, String value) {
requestHeaders.put(key, value);
if ("Content-Type".equalsIgnoreCase(key)) {
contentTypeValue = 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();
}
}
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) {}
}
}

View File

@@ -3,6 +3,7 @@ package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertNotNull;
import android.content.Intent;
import android.view.Gravity;
@@ -226,6 +227,103 @@ public class ProjectDetailActivityUiTest {
assertFalse(viewTreeContainsText(attachmentView, "你 · 09:26"));
}
@Test
public void pendingDispatchPlanViewShowsConfirmAndRejectActions() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-group")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "协作群");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
ReflectionHelpers.setField(activity, "projectCollaborationMode", "approval_required");
ReflectionHelpers.setField(activity, "projectApprovalState", "pending_user");
JSONObject dispatchPlan = new JSONObject()
.put("planId", "dispatch-plan-1")
.put("summary", "主 Agent 建议先把任务下发给 Boss 移动控制台。")
.put("targets", new JSONArray().put(new JSONObject()
.put("projectId", "thread-1")
.put("threadDisplayName", "Boss 移动控制台")
.put("reason", "最近活跃")));
View card = ReflectionHelpers.callInstanceMethod(
activity,
"buildPendingDispatchPlanView",
ReflectionHelpers.ClassParameter.from(JSONObject.class, dispatchPlan)
);
assertTrue(viewTreeContainsText(card, "确认下发"));
assertTrue(viewTreeContainsText(card, "拒绝"));
}
@Test
public void renderProjectShowsRepairEntryForDirtyGroupAndOpensGroupInfo() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "group-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "巡检协作群");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderProject",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildGroupProjectPayload()),
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildRepairParticipantsPayload())
);
View repairView = findClickableViewContainingText(
activity.findViewById(R.id.screen_content),
"去修复"
);
assertNotNull(repairView);
assertTrue(viewTreeContainsText(activity.findViewById(R.id.screen_content), "修复群成员"));
repairView.performClick();
Intent nextIntent = org.robolectric.Shadows.shadowOf(activity).getNextStartedActivity();
assertNotNull(nextIntent);
assertEquals(GroupInfoActivity.class.getName(), nextIntent.getComponent().getClassName());
assertEquals("group-1", nextIntent.getStringExtra(GroupInfoActivity.EXTRA_PROJECT_ID));
}
private static JSONObject buildGroupProjectPayload() throws Exception {
JSONObject threadMeta = new JSONObject()
.put("threadId", "group-thread-3")
.put("folderName", "Boss");
JSONObject project = new JSONObject()
.put("id", "group-1")
.put("name", "巡检协作群")
.put("isGroup", true)
.put("collaborationMode", "approval_required")
.put("approvalState", "pending_user")
.put("threadMeta", threadMeta)
.put("messages", new JSONArray());
return new JSONObject().put("project", project);
}
private static JSONObject buildRepairParticipantsPayload() throws Exception {
return new JSONObject()
.put("participants", new JSONArray()
.put(new JSONObject()
.put("projectId", "master-agent")
.put("threadDisplayName", "主 Agent 汇总")
.put("folderName", "主控线程")
.put("deviceId", "Mac Studio")
.put("threadId", "master-agent-thread")
.put("status", "invalid_target")
.put("statusLabel", "不是可下发线程")
.put("canOpenProject", true)))
.put("repairRequired", true)
.put("repairReason", "当前群聊里有失效或不可下发的线程引用,请重新整理群成员。")
.put("validParticipantCount", 0)
.put("invalidParticipantCount", 1);
}
private static View buildBoundMessageView(TestProjectDetailActivity activity, String messageId, String body) {
TextView messageView = new TextView(activity);
messageView.setText(body);
@@ -260,6 +358,26 @@ public class ProjectDetailActivityUiTest {
return false;
}
private static View findClickableViewContainingText(View root, String expectedText) {
if (root == null) {
return null;
}
if (viewTreeContainsText(root, expectedText) && root.isClickable()) {
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;
}
public static class TestProjectDetailActivity extends ProjectDetailActivity {
@Override
boolean shouldLoadOnCreate() {