feat: ship enterprise control and desktop governance

This commit is contained in:
AI Bot
2026-05-11 14:59:26 +08:00
parent 0757d07521
commit a311280238
285 changed files with 48574 additions and 2428 deletions

View File

@@ -0,0 +1,145 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertTrue;
import android.content.Intent;
import android.view.View;
import android.view.ViewGroup;
import android.widget.TextView;
import org.json.JSONArray;
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.util.ReflectionHelpers;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class AccessManagementActivityTest {
@Test
public void renderAccessShowsTemplateApplyEntryWhenTemplatesAreAvailable() throws Exception {
TestAccessManagementActivity activity = Robolectric
.buildActivity(TestAccessManagementActivity.class, new Intent())
.setup()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderAccess",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildAccessPayload())
);
View content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "套用模板"));
assertTrue(viewTreeContainsText(content, "一次性给账号分配设备、项目和 Skill 权限"));
}
@Test
public void renderAccessExplainsUnavailableTargetsInsteadOfBlankState() throws Exception {
TestAccessManagementActivity activity = Robolectric
.buildActivity(TestAccessManagementActivity.class, new Intent())
.setup()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderAccess",
ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject()
.put("accounts", new JSONArray())
.put("devices", new JSONArray())
.put("projects", new JSONArray())
.put("skills", new JSONArray())
.put("skillCatalog", new JSONArray())
.put("permissionTemplates", new JSONArray())
.put("grants", new JSONObject()
.put("devices", new JSONArray())
.put("projects", new JSONArray())
.put("skills", new JSONArray())))
);
View content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "暂无权限模板"));
assertTrue(viewTreeContainsText(content, "暂无可授权设备"));
assertTrue(viewTreeContainsText(content, "暂无可授权项目"));
assertTrue(viewTreeContainsText(content, "暂无可分配 Skill"));
}
@Test
public void buildTemplateApplyPayloadWritesServerTemplateContract() throws Exception {
JSONObject payload = AccessManagementActivity.buildTemplateApplyPayload(
"developer@example.com",
new JSONObject().put("templateId", "developer"),
new JSONObject().put("id", "mac-studio"),
new JSONObject().put("id", "master-agent"),
new JSONObject().put("skillId", "mac-studio:boss-server-debug")
);
assertEquals("apply_template", payload.optString("action"));
assertEquals("developer@example.com", payload.optString("account"));
assertEquals("developer", payload.optString("templateId"));
assertEquals("mac-studio", payload.optJSONArray("deviceIds").optString(0));
assertEquals("master-agent", payload.optJSONArray("projectIds").optString(0));
assertEquals("mac-studio:boss-server-debug", payload.optJSONArray("skillIds").optString(0));
}
private static JSONObject buildAccessPayload() throws Exception {
return new JSONObject()
.put("accounts", new JSONArray()
.put(new JSONObject()
.put("account", "developer@example.com")
.put("displayName", "Developer")
.put("role", "member")))
.put("devices", new JSONArray()
.put(new JSONObject()
.put("id", "mac-studio")
.put("name", "Mac Studio")))
.put("projects", new JSONArray()
.put(new JSONObject()
.put("id", "master-agent")
.put("name", "主 Agent")))
.put("skills", new JSONArray()
.put(new JSONObject()
.put("skillId", "mac-studio:boss-server-debug")
.put("deviceId", "mac-studio")
.put("name", "boss-server-debug")))
.put("skillCatalog", new JSONArray())
.put("permissionTemplates", new JSONArray()
.put(new JSONObject()
.put("templateId", "developer")
.put("name", "项目开发者")
.put("description", "允许聊天和 Skill 调用")))
.put("grants", new JSONObject()
.put("devices", new JSONArray())
.put("projects", new JSONArray())
.put("skills", new JSONArray()));
}
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 final class TestAccessManagementActivity extends AccessManagementActivity {
@Override
protected void reload() {
}
}
}

View File

@@ -81,6 +81,22 @@ public class BossApiClientDispatchPlansTest {
assertEquals("no-cache", connection.getRequestProperty("Pragma"));
}
@Test
public void protectedHtmlResponseReturnsJsonErrorInsteadOfThrowing() throws Exception {
RecordingConnection connection = new RecordingConnection(
new URL("https://boss.hyzq.net/api/auth/session"),
200,
"<!DOCTYPE html><html><body>login</body></html>",
""
);
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.getSession();
assertEquals(401, response.statusCode);
assertEquals("NON_JSON_RESPONSE", response.message());
}
@Test
public void confirmDispatchPlanWritesApprovedTargetProjectIds() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/p1/dispatch-plans/plan-1/confirm"));
@@ -114,6 +130,19 @@ public class BossApiClientDispatchPlansTest {
assertEquals("{}", connection.requestBody());
}
@Test
public void decideDialogGuardInterventionUsesContractEndpointAndDecisionBody() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/dialog-guard/interventions/intervention-1/decision"));
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.decideDialogGuardIntervention("intervention-1", "allow_once");
assertEquals(200, response.statusCode);
assertEquals("/api/v1/dialog-guard/interventions/intervention-1/decision", apiClient.lastPath);
assertEquals("POST", connection.requestMethodValue);
assertEquals("{\"decision\":\"allow_once\"}", connection.requestBody());
}
@Test
public void retryDispatchPlanUsesProjectScopedRetryEndpoint() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/p1/dispatch-plans/plan-1/retry"));
@@ -282,6 +311,153 @@ public class BossApiClientDispatchPlansTest {
assertEquals(20000, connection.readTimeoutValue);
}
@Test
public void deleteProjectMessageUsesProjectScopedDeleteEndpoint() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/projects/thread-1/messages"));
RecordingBossApiClient apiClient = new RecordingBossApiClient(connection);
BossApiClient.ApiResponse response = apiClient.deleteProjectMessage("thread-1", "msg-1");
assertEquals(200, response.statusCode);
assertEquals("/api/v1/projects/thread-1/messages?messageId=msg-1", apiClient.lastPath);
assertEquals("DELETE", connection.requestMethodValue);
}
@Test
public void storageConfigMethodsUseDedicatedStorageEndpoints() throws Exception {
RecordingConnection getConnection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/storage/config"));
RecordingBossApiClient getClient = new RecordingBossApiClient(getConnection);
getClient.getAttachmentStorageConfig();
assertEquals("/api/v1/storage/config", getClient.lastPath);
assertEquals("GET", getConnection.requestMethodValue);
RecordingConnection saveConnection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/storage/config"));
RecordingBossApiClient saveClient = new RecordingBossApiClient(saveConnection);
saveClient.saveAttachmentStorageConfig(new JSONObject().put("mode", "server_file"));
assertEquals("/api/v1/storage/config", saveClient.lastPath);
assertEquals("PATCH", saveConnection.requestMethodValue);
assertEquals("{\"mode\":\"server_file\"}", saveConnection.requestBody());
RecordingConnection validateConnection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/storage/config/validate"));
RecordingBossApiClient validateClient = new RecordingBossApiClient(validateConnection);
validateClient.validateAttachmentStorageConfig(new JSONObject().put("mode", "oss"));
assertEquals("/api/v1/storage/config/validate", validateClient.lastPath);
assertEquals("POST", validateConnection.requestMethodValue);
assertEquals("{\"mode\":\"oss\"}", validateConnection.requestBody());
}
@Test
public void protectedRequestFallsBackToAutoLoginWhenNoRestoreTokenExists() throws Exception {
SequencedBossApiClient apiClient = new SequencedBossApiClient(
new RecordingConnection(
new URL("https://boss.hyzq.net/api/v1/projects/project-1"),
401,
"{\"ok\":false,\"message\":\"UNAUTHORIZED\"}",
"{\"ok\":false,\"message\":\"UNAUTHORIZED\"}"
),
new RecordingConnection(
new URL("https://boss.hyzq.net/api/v1/projects/project-1"),
200,
"{\"ok\":true,\"project\":{\"id\":\"project-1\",\"name\":\"北区试产线\"}}",
"{\"ok\":false}"
)
);
BossApiClient.ApiResponse response = apiClient.getProjectDetail("project-1");
assertEquals(1, apiClient.autoLoginCalls);
assertEquals(2, apiClient.protectedRequestCount);
assertEquals(200, response.statusCode);
assertEquals("北区试产线", response.json.optJSONObject("project").optString("name"));
}
@Test
public void autoLoginCapturesSessionCookieFromMixedCaseHeaderNames() throws Exception {
InMemorySharedPreferences prefs = new InMemorySharedPreferences();
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/auth/login"));
connection.responseHeaders.put(
"Set-cookie",
Collections.singletonList("boss_session=session-from-mixed-case; Path=/; HttpOnly")
);
IdentityCapturingBossApiClient apiClient = new IdentityCapturingBossApiClient(connection, prefs);
BossApiClient.ApiResponse response = apiClient.autoLogin();
assertEquals(200, response.statusCode);
assertEquals("boss_session=session-from-mixed-case", prefs.getString("session_cookie", ""));
assertEquals("krisolo", prefs.getString("account", ""));
assertEquals("Boss 超级管理员", prefs.getString("display_name", ""));
}
@Test
public void loginWithPasswordPostsCredentialsAndCapturesNativeRestoreToken() throws Exception {
InMemorySharedPreferences prefs = new InMemorySharedPreferences();
RecordingConnection connection = new RecordingConnection(
new URL("https://boss.hyzq.net/api/auth/login"),
200,
"{\"ok\":true,\"account\":\"krisolo\",\"displayName\":\"Boss 超级管理员\",\"restoreToken\":\"restore-login\"}",
"{\"ok\":false}"
);
connection.responseHeaders.put(
"Set-cookie",
Collections.singletonList("boss_session=session-from-login; Path=/; HttpOnly")
);
IdentityCapturingBossApiClient apiClient = new IdentityCapturingBossApiClient(connection, prefs);
BossApiClient.ApiResponse response = apiClient.loginWithPassword("krisolo", "Admin_yqs_asd.");
assertEquals(200, response.statusCode);
assertEquals("/api/auth/login", apiClient.lastPath);
assertEquals("POST", connection.requestMethodValue);
assertEquals(
"{\"account\":\"krisolo\",\"password\":\"Admin_yqs_asd.\",\"method\":\"password\"}",
connection.requestBody()
);
assertEquals("boss_session=session-from-login", prefs.getString("session_cookie", ""));
assertEquals("restore-login", prefs.getString("restore_token", ""));
assertEquals("krisolo", prefs.getString("account", ""));
}
@Test
public void authRegistrationAndPasswordResetUseDedicatedNativeRoutes() throws Exception {
ScriptedBossApiClient apiClient = new ScriptedBossApiClient(
new RecordingConnection(new URL("https://boss.hyzq.net/api/auth/send-code")),
new RecordingConnection(new URL("https://boss.hyzq.net/api/auth/register")),
new RecordingConnection(new URL("https://boss.hyzq.net/api/auth/forgot-password"))
);
BossApiClient.ApiResponse codeResponse = apiClient.sendVerificationCode("new-user", "register");
assertEquals(200, codeResponse.statusCode);
assertEquals("/api/auth/send-code", apiClient.lastPath);
assertEquals("{\"account\":\"new-user\",\"purpose\":\"register\"}", apiClient.lastConnection.requestBody());
BossApiClient.ApiResponse registerResponse = apiClient.registerAccount(
"new-user",
"New_password_123",
"New_password_123",
"123456"
);
assertEquals(200, registerResponse.statusCode);
assertEquals("/api/auth/register", apiClient.lastPath);
assertEquals(
"{\"account\":\"new-user\",\"password\":\"New_password_123\",\"confirmPassword\":\"New_password_123\",\"code\":\"123456\"}",
apiClient.lastConnection.requestBody()
);
BossApiClient.ApiResponse resetResponse = apiClient.resetPassword(
"new-user",
"Reset_password_123",
"Reset_password_123",
"654321"
);
assertEquals(200, resetResponse.statusCode);
assertEquals("/api/auth/forgot-password", apiClient.lastPath);
assertEquals(
"{\"account\":\"new-user\",\"password\":\"Reset_password_123\",\"confirmPassword\":\"Reset_password_123\",\"code\":\"654321\"}",
apiClient.lastConnection.requestBody()
);
}
@Test
public void onboardOpenAiApiAccountUsesDedicatedRouteAndSetsActive() throws Exception {
RecordingConnection connection = new RecordingConnection(new URL("https://boss.hyzq.net/api/v1/accounts/onboard/openai-api"));
@@ -308,7 +484,7 @@ public class BossApiClientDispatchPlansTest {
public void rememberIdentityDoesNotOverwriteSessionIdentityFromAiAccountOnboardingResponse() throws Exception {
InMemorySharedPreferences prefs = new InMemorySharedPreferences();
prefs.edit()
.putString("account", "17600003315")
.putString("account", "krisolo")
.putString("display_name", "Boss 超级管理员")
.apply();
BossApiClient apiClient = new BossApiClient(prefs, "https://boss.hyzq.net");
@@ -321,7 +497,7 @@ public class BossApiClientDispatchPlansTest {
apiClient.rememberIdentity(onboardingResponse);
assertEquals("17600003315", apiClient.getAccountLabel());
assertEquals("krisolo", apiClient.getAccountLabel());
assertEquals("Boss 超级管理员", apiClient.getDisplayName());
}
@@ -359,7 +535,11 @@ public class BossApiClientDispatchPlansTest {
private String lastPath = "";
RecordingBossApiClient(RecordingConnection connection) {
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
this(connection, new InMemorySharedPreferences());
}
RecordingBossApiClient(RecordingConnection connection, SharedPreferences prefs) {
super(prefs, "https://boss.hyzq.net");
this.connection = connection;
}
@@ -383,6 +563,7 @@ public class BossApiClientDispatchPlansTest {
private static final class ScriptedBossApiClient extends BossApiClient {
private final Map<String, RecordingConnection> connections;
private String lastPath = "";
private RecordingConnection lastConnection;
ScriptedBossApiClient(RecordingConnection... connections) {
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
@@ -399,6 +580,7 @@ public class BossApiClientDispatchPlansTest {
if (connection == null) {
throw new IllegalStateException("Missing scripted connection for " + path);
}
lastConnection = connection;
return connection;
}
@@ -413,6 +595,65 @@ public class BossApiClientDispatchPlansTest {
}
}
private static final class SequencedBossApiClient extends BossApiClient {
private final java.util.ArrayDeque<RecordingConnection> protectedConnections = new java.util.ArrayDeque<>();
private int autoLoginCalls;
private int protectedRequestCount;
SequencedBossApiClient(RecordingConnection... protectedConnections) {
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
Collections.addAll(this.protectedConnections, protectedConnections);
}
@Override
public ApiResponse autoLogin() throws org.json.JSONException {
autoLoginCalls += 1;
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("account", "krisolo")
.put("displayName", "Boss 超级管理员"));
}
@Override
HttpURLConnection openConnection(String path) {
if (!"/api/v1/projects/project-1".equals(path)) {
throw new IllegalStateException("Unexpected path " + path);
}
protectedRequestCount += 1;
RecordingConnection connection = protectedConnections.pollFirst();
if (connection == null) {
throw new IllegalStateException("No more scripted protected responses");
}
return connection;
}
@Override
String encode(String value) {
return value;
}
@Override
void rememberIdentity(JSONObject json) {
// no-op for JVM unit test
}
}
private static final class IdentityCapturingBossApiClient extends BossApiClient {
private final RecordingConnection connection;
private String lastPath = "";
IdentityCapturingBossApiClient(RecordingConnection connection, SharedPreferences prefs) {
super(prefs, "https://boss.hyzq.net");
this.connection = connection;
}
@Override
HttpURLConnection openConnection(String path) {
lastPath = path;
return connection;
}
}
private static final class RecordingConnection extends HttpURLConnection {
private final ByteArrayOutputStream requestBody = new ByteArrayOutputStream();
private final Map<String, String> requestHeaders = new HashMap<>();
@@ -422,9 +663,15 @@ public class BossApiClientDispatchPlansTest {
private final int responseCodeValue;
private final String responseBody;
private final String errorBody;
private final Map<String, java.util.List<String>> responseHeaders = new HashMap<>();
RecordingConnection(URL url) {
this(url, 200, "{\"ok\":true}", "{\"ok\":false}");
this(
url,
200,
"{\"ok\":true,\"account\":\"krisolo\",\"displayName\":\"Boss 超级管理员\"}",
"{\"ok\":false}"
);
}
RecordingConnection(URL url, int responseCodeValue, String responseBody, String errorBody) {
@@ -493,6 +740,11 @@ public class BossApiClientDispatchPlansTest {
return new ByteArrayInputStream(errorBody.getBytes(StandardCharsets.UTF_8));
}
@Override
public Map<String, java.util.List<String>> getHeaderFields() {
return responseHeaders;
}
String requestBody() {
return requestBody.toString(StandardCharsets.UTF_8);
}

View File

@@ -0,0 +1,116 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import android.app.NotificationManager;
import android.content.Context;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.content.SharedPreferences;
import org.junit.After;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.RuntimeEnvironment;
import org.robolectric.annotation.Config;
import org.robolectric.Shadows;
import org.robolectric.shadows.ShadowNotificationManager;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class BossBackgroundRealtimeServiceTest {
@After
public void tearDown() {
TestBossBackgroundRealtimeService.runtimeOverride = null;
}
@Test
public void manifestDeclaresForegroundDataSyncPermission() throws Exception {
Context context = RuntimeEnvironment.getApplication();
PackageManager packageManager = context.getPackageManager();
PackageInfo packageInfo = packageManager.getPackageInfo(
context.getPackageName(),
PackageManager.GET_PERMISSIONS
);
assertNotNull(packageInfo.requestedPermissions);
org.junit.Assert.assertTrue(
java.util.Arrays.asList(packageInfo.requestedPermissions)
.contains("android.permission.FOREGROUND_SERVICE_DATA_SYNC")
);
}
@Test
public void startCommandStartsForegroundSyncAndRealtimeWhenSessionExists() {
Context context = RuntimeEnvironment.getApplication();
SharedPreferences prefs = context.getSharedPreferences("boss-background-service", Context.MODE_PRIVATE);
prefs.edit()
.putString("session_cookie", "boss_session=test")
.putString("restore_token", "restore-test")
.apply();
RecordingRealtimeRuntime runtime = new RecordingRealtimeRuntime();
TestBossBackgroundRealtimeService.runtimeOverride = runtime;
TestBossBackgroundRealtimeService service = Robolectric
.buildService(TestBossBackgroundRealtimeService.class)
.create()
.startCommand(0, 1)
.get();
ShadowNotificationManager notificationManager = Shadows.shadowOf(
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)
);
assertEquals(1, runtime.startCount);
assertEquals(
1,
notificationManager.size()
);
assertEquals(
"Boss 后台同步中",
String.valueOf(
notificationManager
.getNotification(BossBackgroundRealtimeService.SERVICE_NOTIFICATION_ID)
.extras
.getCharSequence(android.app.Notification.EXTRA_TITLE)
)
);
service.onDestroy();
assertEquals(1, runtime.stopCount);
}
public static class TestBossBackgroundRealtimeService extends BossBackgroundRealtimeService {
static RecordingRealtimeRuntime runtimeOverride;
@Override
BossRealtimeRuntime createRealtimeRuntime(BossApiClient apiClient, BossNotificationRouter router) {
return runtimeOverride == null ? super.createRealtimeRuntime(apiClient, router) : runtimeOverride;
}
@Override
BossApiClient createApiClient() {
Context context = RuntimeEnvironment.getApplication();
SharedPreferences prefs = context.getSharedPreferences("boss-background-service", Context.MODE_PRIVATE);
return new BossApiClient(prefs, "https://boss.hyzq.net");
}
}
static final class RecordingRealtimeRuntime implements BossBackgroundRealtimeService.BossRealtimeRuntime {
int startCount;
int stopCount;
@Override
public void start() {
startCount += 1;
}
@Override
public void stop() {
stopCount += 1;
}
}
}

View File

@@ -0,0 +1,133 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import android.app.Notification;
import android.app.NotificationManager;
import android.content.Context;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RuntimeEnvironment;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import org.robolectric.Shadows;
import org.robolectric.shadows.ShadowNotificationManager;
import org.robolectric.shadows.ShadowApplication;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class BossNotificationRouterTest {
@Test
public void visibilityTrackerMarksForegroundAndVisibleProject() {
BossAppVisibilityTracker tracker = new BossAppVisibilityTracker();
tracker.onAppForegrounded();
tracker.setVisibleProjectId("master-agent");
assertTrue(tracker.isAppInForeground());
assertEquals("master-agent", tracker.getVisibleProjectId());
tracker.clearVisibleProjectId("master-agent");
tracker.onAppBackgrounded();
assertFalse(tracker.isAppInForeground());
assertNull(tracker.getVisibleProjectId());
}
@Test
public void routerNotifiesOnlyForNewMasterAgentRepliesWhileBackgrounded() throws Exception {
Context context = RuntimeEnvironment.getApplication();
BossAppVisibilityTracker tracker = new BossAppVisibilityTracker();
tracker.onAppBackgrounded();
BossNotificationRouter router = new BossNotificationRouter(context, tracker);
ShadowNotificationManager notificationManager = Shadows.shadowOf(
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)
);
ShadowApplication.getInstance().grantPermissions(android.Manifest.permission.POST_NOTIFICATIONS);
JSONObject message = new JSONObject()
.put("id", "m-2")
.put("sender", "master")
.put("senderLabel", "主 Agent · gpt-5.4-mini")
.put("body", "主 Agent 已完成同步。")
.put("sentAt", "2026-04-21T10:00:00.000Z");
JSONObject payload = new JSONObject()
.put("projectId", "master-agent")
.put("projectMessagesPayload", new JSONObject().put(
"project",
new JSONObject().put("messages", new JSONArray().put(message))
));
assertTrue(router.maybeNotifyForRealtimeEvent(new BossRealtimeEvent("project.messages.updated", payload)));
assertFalse(router.maybeNotifyForRealtimeEvent(new BossRealtimeEvent("project.messages.updated", payload)));
assertEquals(1, notificationManager.size());
Notification posted = notificationManager.getNotification(BossNotificationRouter.MASTER_AGENT_NOTIFICATION_ID);
assertEquals("主 Agent", String.valueOf(posted.extras.getCharSequence(Notification.EXTRA_TITLE)));
assertEquals("主 Agent 已完成同步。", String.valueOf(posted.extras.getCharSequence(Notification.EXTRA_TEXT)));
}
@Test
public void routerNotifiesForMasterAgentRepliesInsideThreadConversationsWhileBackgrounded() throws Exception {
Context context = RuntimeEnvironment.getApplication();
BossAppVisibilityTracker tracker = new BossAppVisibilityTracker();
tracker.onAppBackgrounded();
BossNotificationRouter router = new BossNotificationRouter(context, tracker);
ShadowNotificationManager notificationManager = Shadows.shadowOf(
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)
);
ShadowApplication.getInstance().grantPermissions(android.Manifest.permission.POST_NOTIFICATIONS);
JSONObject message = new JSONObject()
.put("id", "thread-master-reply-1")
.put("sender", "master")
.put("senderLabel", "主 Agent · gpt-5.4-mini")
.put("body", "我已接管这个线程,下一步先核对当前目标。");
JSONObject payload = new JSONObject()
.put("projectId", "aiyanjing-thread")
.put("projectMessagesPayload", new JSONObject().put(
"project",
new JSONObject()
.put("name", "AI 眼镜线程")
.put("messages", new JSONArray().put(message))
));
assertTrue(router.maybeNotifyForRealtimeEvent(new BossRealtimeEvent("project.messages.updated", payload)));
Notification posted = notificationManager.getNotification(BossNotificationRouter.MASTER_AGENT_NOTIFICATION_ID);
assertEquals("主 Agent · AI 眼镜线程", String.valueOf(posted.extras.getCharSequence(Notification.EXTRA_TITLE)));
assertEquals("我已接管这个线程,下一步先核对当前目标。", String.valueOf(posted.extras.getCharSequence(Notification.EXTRA_TEXT)));
}
@Test
public void routerSuppressesNotificationWhileAppIsForeground() throws Exception {
Context context = RuntimeEnvironment.getApplication();
BossAppVisibilityTracker tracker = new BossAppVisibilityTracker();
tracker.onAppForegrounded();
BossNotificationRouter router = new BossNotificationRouter(context, tracker);
ShadowNotificationManager notificationManager = Shadows.shadowOf(
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)
);
ShadowApplication.getInstance().grantPermissions(android.Manifest.permission.POST_NOTIFICATIONS);
JSONObject message = new JSONObject()
.put("id", "m-3")
.put("sender", "master")
.put("senderLabel", "主 Agent · gpt-5.4-mini")
.put("body", "这条前台不该弹通知。");
JSONObject payload = new JSONObject()
.put("projectId", "master-agent")
.put("projectMessagesPayload", new JSONObject().put(
"project",
new JSONObject().put("messages", new JSONArray().put(message))
));
assertFalse(router.maybeNotifyForRealtimeEvent(new BossRealtimeEvent("project.messages.updated", payload)));
assertEquals(0, notificationManager.size());
}
}

View File

@@ -0,0 +1,42 @@
package com.hyzq.boss;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class BossRbacVisibilityTest {
@Test
public void memberMeMenuHidesAdministratorControlEntries() {
assertArrayEquals(
new String[]{"账号与安全", "设置", "技能", "关于"},
WechatSurfaceMapper.rootMeMenuTitlesForRole("member")
);
assertFalse(WechatSurfaceMapper.canOpenMeEntryForRole("access", "member"));
assertFalse(WechatSurfaceMapper.canOpenMeEntryForRole("ai_accounts", "member"));
assertFalse(WechatSurfaceMapper.canOpenMeEntryForRole("ops", "member"));
assertFalse(WechatSurfaceMapper.canOpenMeEntryForRole("storage", "member"));
assertFalse(WechatSurfaceMapper.canOpenMeEntryForRole("telegram", "member"));
assertTrue(WechatSurfaceMapper.canOpenMeEntryForRole("skills", "member"));
}
@Test
public void administratorMeMenuKeepsControlEntries() {
assertArrayEquals(
new String[]{"账号与安全", "设置", "用户与权限", "运维与修复", "AI 账号", "附件与存储", "Telegram 接入", "技能", "关于"},
WechatSurfaceMapper.rootMeMenuTitlesForRole("highest_admin")
);
assertTrue(WechatSurfaceMapper.canOpenMeEntryForRole("access", "highest_admin"));
assertFalse(WechatSurfaceMapper.canOpenMeEntryForRole("access", "admin"));
assertTrue(WechatSurfaceMapper.canOpenMeEntryForRole("ai_accounts", "highest_admin"));
assertTrue(WechatSurfaceMapper.canOpenMeEntryForRole("ops", "admin"));
}
}

View File

@@ -1,10 +1,13 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import android.content.Intent;
import android.view.View;
import android.widget.LinearLayout;
import android.widget.Button;
import android.widget.TextView;
import org.json.JSONObject;
@@ -13,6 +16,7 @@ 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.util.ReflectionHelpers;
@RunWith(RobolectricTestRunner.class)
@@ -21,31 +25,41 @@ public class BossUiRootSurfaceTest {
@Test
public void renderMeRoot_usesWechatProfileHeaderAndFlatMenuRows() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
ReflectionHelpers.callInstanceMethod(
activity,
"setActiveTab",
ReflectionHelpers.ClassParameter.from(String.class, "me"),
ReflectionHelpers.ClassParameter.from(boolean.class, false)
);
ReflectionHelpers.setField(
activity,
"sessionData",
new JSONObject()
.put("displayName", "Kris")
.put("account", "17600003315")
.put("account", "krisolo")
.put("role", "highest_admin")
);
ReflectionHelpers.callInstanceMethod(activity, "renderMeRoot");
LinearLayout content = activity.findViewById(R.id.screen_content);
assertEquals("我的页应是资料头 + 6 条菜单", 7, content.getChildCount());
LinearLayout content = ReflectionHelpers.getField(activity, "screenContent");
assertEquals("我的页应是资料头 + 9 条菜单", 10, content.getChildCount());
View header = content.getChildAt(0);
assertEquals("资料头不应保留浮层卡片感", 0f, header.getElevation(), 0.01f);
assertTrue(viewTreeContainsText(header, "Kris"));
assertTrue(viewTreeContainsText(header, "17600003315"));
assertTrue(viewTreeContainsText(header, "krisolo"));
assertTrue(viewTreeContainsText(header, "最高管理员"));
assertTrue(viewTreeContainsText(header, "主控账号已启用安全保护"));
assertTrue(viewTreeContainsText(content, "账号与安全"));
assertTrue(viewTreeContainsText(content, "设置"));
assertTrue(viewTreeContainsText(content, "用户与权限"));
assertTrue(viewTreeContainsText(content, "运维与修复"));
assertTrue(viewTreeContainsText(content, "AI 账号"));
assertTrue(viewTreeContainsText(content, "附件与存储"));
assertTrue(viewTreeContainsText(content, "Telegram 接入"));
assertTrue(viewTreeContainsText(content, "技能"));
assertTrue(viewTreeContainsText(content, "关于"));
@@ -55,6 +69,44 @@ public class BossUiRootSurfaceTest {
}
}
@Test
public void openMeEntry_storageStartsAttachmentStorageSettings() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.setField(
activity,
"sessionData",
new JSONObject().put("role", "highest_admin")
);
ReflectionHelpers.callInstanceMethod(
activity,
"openMeEntry",
ReflectionHelpers.ClassParameter.from(String.class, "storage")
);
Intent started = Shadows.shadowOf(activity).getNextStartedActivity();
assertNotNull(started);
assertEquals(StorageSettingsActivity.class.getName(), started.getComponent().getClassName());
}
@Test
public void rootTabs_useWechatIconLabelNavigation() {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
Button conversations = activity.findViewById(R.id.tab_conversations);
Button devices = activity.findViewById(R.id.tab_devices);
Button me = activity.findViewById(R.id.tab_me);
assertEquals("会话", conversations.getText().toString());
assertEquals("设备", devices.getText().toString());
assertEquals("我的", me.getText().toString());
assertNotNull("会话 tab 应显示顶部图标", conversations.getCompoundDrawables()[1]);
assertNotNull("设备 tab 应显示顶部图标", devices.getCompoundDrawables()[1]);
assertNotNull("我的 tab 应显示顶部图标", me.getCompoundDrawables()[1]);
assertEquals("底栏文字应压成微信式小字号", 12f, conversations.getTextSize() / activity.getResources().getDisplayMetrics().scaledDensity, 0.5f);
}
private static boolean viewTreeContainsText(View root, String expectedText) {
if (root instanceof TextView) {
CharSequence text = ((TextView) root).getText();

View File

@@ -22,6 +22,7 @@ import org.robolectric.RobolectricTestRunner;
import org.robolectric.Shadows;
import org.robolectric.annotation.Config;
import org.robolectric.shadows.ShadowDialog;
import java.time.Duration;
import org.robolectric.util.ReflectionHelpers;
@RunWith(RobolectricTestRunner.class)
@@ -145,7 +146,7 @@ public class ConversationFolderActivityTest {
new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-2"))
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
assertEquals(1, activity.reloadCount);
}

View File

@@ -7,6 +7,8 @@ import static org.junit.Assert.assertTrue;
import android.content.Context;
import android.content.Intent;
import android.graphics.Color;
import android.graphics.drawable.ColorDrawable;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageButton;
@@ -15,6 +17,7 @@ import android.widget.ListView;
import android.widget.TextView;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.widget.SwitchCompat;
import org.json.JSONArray;
import org.json.JSONObject;
@@ -37,7 +40,7 @@ import java.util.concurrent.TimeUnit;
@Config(sdk = 34)
public class ConversationInfoActivityTest {
@Test
public void renderConversationUsesLightweightHeaderMenuAndThreadList() throws Exception {
public void renderConversationOmitsProfileHeaderAndStartsWithUsefulSettings() throws Exception {
Intent intent = new Intent()
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
@@ -55,22 +58,81 @@ public class ConversationInfoActivityTest {
);
LinearLayout content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content.getChildAt(0), "北区试产线回归"));
assertTrue(viewTreeContainsText(content.getChildAt(0), "单线程会话"));
assertTrue(viewTreeContainsText(content.getChildAt(1), "线程状态摘要"));
assertTrue(viewTreeContainsTextFragment(content.getChildAt(1), "当前进度:已经记录最近 2 条进展"));
assertTrue(viewTreeContainsTextFragment(content.getChildAt(1), "建议下一步:继续同步 Android 只读页"));
assertTrue(viewTreeContainsText(content.getChildAt(2), "主 Agent 协同接管"));
assertTrue(viewTreeContainsText(content.getChildAt(3), "发起群聊"));
assertTrue(viewTreeContainsText(content.getChildAt(3), "选择其他线程加入新群"));
assertTrue(viewTreeContainsText(content.getChildAt(4), "线程详情"));
assertTrue(viewTreeContainsText(content.getChildAt(4), "查看当前线程聊天与项目"));
assertFalse(viewTreeContainsText(content, "线程状态摘要"));
assertFalse(viewTreeContainsTextFragment(content, "当前进度:已经记录最近 2 条进展"));
assertFalse(viewTreeContainsTextFragment(content, "建议下一步:继续同步 Android 只读页"));
assertFalse(viewTreeContainsText(content, "单线程会话"));
assertTrue(viewTreeContainsText(content.getChildAt(0), "主 Agent 协同接管"));
assertTrue(viewTreeContainsText(content.getChildAt(1), "发起群聊"));
assertTrue(viewTreeContainsText(content.getChildAt(1), "选择其他线程加入新群"));
assertTrue(viewTreeContainsText(content.getChildAt(2), "线程详情"));
assertTrue(viewTreeContainsText(content.getChildAt(2), "查看当前线程聊天与项目"));
assertTrue(viewTreeContainsText(content, "参与线程"));
assertTrue(viewTreeContainsText(content, "硬件审计协作"));
assertFalse(viewTreeContainsText(content, "从当前会话选择其他线程,创建新的独立群聊"));
assertFalse(viewTreeContainsText(content, "以下线程参与当前会话,点击可查看对应项目详情。"));
}
@Test
public void takeoverControlUsesWechatRowVisualSystem() throws Exception {
Intent intent = new Intent()
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
TestConversationInfoActivity activity = Robolectric
.buildActivity(TestConversationInfoActivity.class, intent)
.setup()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderConversation",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildDetailPayload()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildParticipantsPayload()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildThreadStatusPayload())
);
LinearLayout content = activity.findViewById(R.id.screen_content);
LinearLayout takeoverRow = (LinearLayout) content.getChildAt(0);
SwitchCompat takeoverSwitch = findFirstSwitch(takeoverRow);
assertEquals(LinearLayout.HORIZONTAL, takeoverRow.getOrientation());
assertEquals(BossUi.dp(activity, 18), takeoverRow.getPaddingLeft());
assertEquals(BossUi.dp(activity, 18), takeoverRow.getPaddingRight());
assertNotNull(takeoverSwitch);
assertEquals("", String.valueOf(takeoverSwitch.getText()));
}
@Test
public void conversationInfoRowsUseConsistentSpacingAndTakeoverHasNoDividerLines() throws Exception {
Intent intent = new Intent()
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
TestConversationInfoActivity activity = Robolectric
.buildActivity(TestConversationInfoActivity.class, intent)
.setup()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"renderConversation",
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildDetailPayload()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildParticipantsPayload()),
ReflectionHelpers.ClassParameter.from(JSONObject.class, buildThreadStatusPayload())
);
LinearLayout content = activity.findViewById(R.id.screen_content);
int expectedBottomMargin = BossUi.dp(activity, 8);
for (int index = 0; index < Math.min(content.getChildCount(), 6); index += 1) {
View child = content.getChildAt(index);
assertTrue(child.getLayoutParams() instanceof LinearLayout.LayoutParams);
assertEquals(expectedBottomMargin, ((LinearLayout.LayoutParams) child.getLayoutParams()).bottomMargin);
}
View takeoverRow = content.getChildAt(0);
assertTrue(takeoverRow.getBackground() instanceof ColorDrawable);
assertEquals(Color.WHITE, ((ColorDrawable) takeoverRow.getBackground()).getColor());
}
@Test
public void threadDetailMenuRowStillOpensProjectDetail() throws Exception {
Intent intent = new Intent()
@@ -235,6 +297,42 @@ public class ConversationInfoActivityTest {
assertEquals(1, apiClient.autoLoginCalls);
}
@Test
public void saveTakeoverSettingReturnsUpdatedResultState() {
Intent intent = new Intent()
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
TestConversationInfoActivity activity = Robolectric
.buildActivity(TestConversationInfoActivity.class, intent)
.setup()
.get();
RecordingBossApiClient apiClient = new RecordingBossApiClient(
activity.getSharedPreferences("conversation-info-save-result-test", Context.MODE_PRIVATE),
"https://boss.hyzq.net"
);
apiClient.failFirstLoad = false;
ReflectionHelpers.setField(activity, "apiClient", apiClient);
ReflectionHelpers.setField(activity, "reloadEnabled", true);
ReflectionHelpers.setField(activity, "delegateReloadToSuper", true);
ReflectionHelpers.setField(activity, "executor", new DirectExecutorService());
activity.reload();
ShadowLooper.shadowMainLooper().idle();
ReflectionHelpers.callInstanceMethod(
activity,
"saveTakeoverSetting",
ReflectionHelpers.ClassParameter.from(boolean.class, true)
);
ShadowLooper.shadowMainLooper().idle();
assertEquals(android.app.Activity.RESULT_OK, Shadows.shadowOf(activity).getResultCode());
Intent resultIntent = Shadows.shadowOf(activity).getResultIntent();
assertNotNull(resultIntent);
assertTrue(resultIntent.getBooleanExtra(ConversationInfoActivity.EXTRA_TAKEOVER_ENABLED, false));
assertEquals("北区试产线回归", resultIntent.getStringExtra(ConversationInfoActivity.EXTRA_PROJECT_NAME));
}
@Test
public void matchingProjectMessagesUpdatedEventTriggersReload() throws Exception {
Intent intent = new Intent()
@@ -393,6 +491,23 @@ public class ConversationInfoActivityTest {
return null;
}
private static SwitchCompat findFirstSwitch(View root) {
if (root instanceof SwitchCompat) {
return (SwitchCompat) root;
}
if (!(root instanceof ViewGroup)) {
return null;
}
ViewGroup group = (ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
SwitchCompat match = findFirstSwitch(group.getChildAt(index));
if (match != null) {
return match;
}
}
return null;
}
public static class TestConversationInfoActivity extends ConversationInfoActivity {
private boolean reloadEnabled;
private boolean delegateReloadToSuper;
@@ -474,7 +589,7 @@ public class ConversationInfoActivityTest {
200,
new JSONObject()
.put("ok", true)
.put("session", new JSONObject().put("account", "17600003315"))
.put("session", new JSONObject().put("account", "krisolo"))
);
}

View File

@@ -297,7 +297,7 @@ public class DeviceDetailActivityTest {
.put("id", "device-1")
.put("name", "Mac Studio")
.put("avatar", "M")
.put("account", "17600003315")
.put("account", "krisolo")
.put("status", "online")
.put("quota5h", 75)
.put("quota7d", 88)

View File

@@ -0,0 +1,279 @@
package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import android.content.Context;
import android.content.SharedPreferences;
import android.view.View;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.Shadows;
import org.robolectric.annotation.Config;
import org.robolectric.util.ReflectionHelpers;
import java.time.Duration;
import java.util.function.BooleanSupplier;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class MainActivityBootstrapSessionTest {
@Test
public void bootstrapSession_withoutSessionHints_showsLoginFormAndDoesNotAutoLogin() throws Exception {
TestBootstrapSessionMainActivity activity =
Robolectric.buildActivity(TestBootstrapSessionMainActivity.class).setup().get();
SharedPreferences prefs = activity.getSharedPreferences("test-bootstrap-session", Context.MODE_PRIVATE);
prefs.edit().clear().apply();
Shadows.shadowOf(android.os.Looper.getMainLooper()).idleFor(Duration.ofMillis(200));
View loginPanel = activity.findViewById(R.id.login_panel);
View contentPanel = activity.findViewById(R.id.content_panel);
android.widget.EditText accountInput = activity.findViewById(R.id.login_account_input);
android.widget.EditText passwordInput = activity.findViewById(R.id.login_password_input);
assertEquals(0, activity.apiClient.autoLoginCalls);
assertEquals(View.VISIBLE, loginPanel.getVisibility());
assertEquals(View.GONE, contentPanel.getVisibility());
assertNotNull(accountInput);
assertNotNull(passwordInput);
assertFalse(accountInput.getHint().toString().isEmpty());
assertFalse(passwordInput.getHint().toString().isEmpty());
}
@Test
public void bootstrapSession_withSessionHints_prefersRestoreAndDoesNotAutoLogin() throws Exception {
TestRestoreBootstrapSessionMainActivity activity =
Robolectric.buildActivity(TestRestoreBootstrapSessionMainActivity.class).setup().get();
waitFor(() -> activity.apiClient.restoreCalls > 0 && activity.apiClient.homeCalls > 0);
View loginPanel = activity.findViewById(R.id.login_panel);
View contentPanel = activity.findViewById(R.id.content_panel);
JSONObject sessionData = ReflectionHelpers.getField(activity, "sessionData");
assertEquals(0, activity.apiClient.autoLoginCalls);
assertEquals(1, activity.apiClient.getSessionCalls);
assertEquals(1, activity.apiClient.restoreCalls);
assertEquals(View.GONE, loginPanel.getVisibility());
assertEquals(View.VISIBLE, contentPanel.getVisibility());
assertNotNull(sessionData);
assertEquals("krisolo", sessionData.optString("account", ""));
}
private static void waitFor(BooleanSupplier condition) {
long deadline = System.currentTimeMillis() + 5_000L;
while (System.currentTimeMillis() < deadline) {
Shadows.shadowOf(android.os.Looper.getMainLooper()).idleFor(Duration.ofMillis(50));
if (condition.getAsBoolean()) {
return;
}
}
throw new AssertionError("Condition not met before timeout");
}
public static class TestBootstrapSessionMainActivity extends MainActivity {
RecordingBootstrapApiClient apiClient;
@Override
BossApiClient createApiClient() {
apiClient = new RecordingBootstrapApiClient(
getSharedPreferences("test-bootstrap-session", Context.MODE_PRIVATE)
);
return apiClient;
}
@Override
BossRealtimeClient createRealtimeClient(BossApiClient client) {
return new BossRealtimeClient(client, new BossRealtimeClient.Listener() {
@Override
public void onRealtimeEvent(BossRealtimeEvent event) {}
});
}
}
public static class TestRestoreBootstrapSessionMainActivity extends MainActivity {
RecordingRestoreBootstrapApiClient apiClient;
@Override
BossApiClient createApiClient() {
apiClient = new RecordingRestoreBootstrapApiClient(
getSharedPreferences("test-bootstrap-session-restore", Context.MODE_PRIVATE)
);
return apiClient;
}
@Override
BossRealtimeClient createRealtimeClient(BossApiClient client) {
return new BossRealtimeClient(client, new BossRealtimeClient.Listener() {
@Override
public void onRealtimeEvent(BossRealtimeEvent event) {}
});
}
}
private static final class RecordingBootstrapApiClient extends BossApiClient {
int autoLoginCalls;
int homeCalls;
int devicesCalls;
int otaCalls;
int settingsCalls;
RecordingBootstrapApiClient(SharedPreferences prefs) {
super(prefs, "https://boss.hyzq.net");
}
@Override
public boolean hasSessionHints() {
return false;
}
@Override
public ApiResponse autoLogin() throws java.io.IOException, org.json.JSONException {
autoLoginCalls += 1;
JSONObject session = new JSONObject()
.put("account", "krisolo")
.put("displayName", "Boss 超级管理员")
.put("restoreToken", "restore-auto");
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("session", session));
}
@Override
public ApiResponse restoreSession() throws java.io.IOException, org.json.JSONException {
return ApiResponse.error(401, new JSONObject().put("ok", false).put("message", "NO_RESTORE_TOKEN"));
}
@Override
public ApiResponse getSession() throws java.io.IOException, org.json.JSONException {
return ApiResponse.error(401, new JSONObject().put("ok", false).put("message", "NO_SESSION"));
}
@Override
public ApiResponse getConversationHome() throws java.io.IOException, org.json.JSONException {
homeCalls += 1;
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("conversations", new JSONArray().put(new JSONObject()
.put("projectId", "master-agent")
.put("conversationType", "master_agent")
.put("projectTitle", "主 Agent")
.put("threadTitle", "主 Agent 汇总")
.put("lastMessagePreview", "最近会话已恢复")
.put("latestReplyLabel", "刚刚"))));
}
@Override
public ApiResponse getDevices() throws java.io.IOException, org.json.JSONException {
devicesCalls += 1;
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("devices", new JSONArray()));
}
@Override
public ApiResponse getOtaStatus() throws java.io.IOException, org.json.JSONException {
otaCalls += 1;
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("hasOta", false));
}
@Override
public ApiResponse getSettings() throws java.io.IOException, org.json.JSONException {
settingsCalls += 1;
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("settings", new JSONObject().put("preferredEntryPoint", "conversations"))
.put("user", new JSONObject()));
}
}
private static final class RecordingRestoreBootstrapApiClient extends BossApiClient {
int autoLoginCalls;
int getSessionCalls;
int restoreCalls;
int homeCalls;
int devicesCalls;
int otaCalls;
int settingsCalls;
RecordingRestoreBootstrapApiClient(SharedPreferences prefs) {
super(prefs, "https://boss.hyzq.net");
prefs.edit()
.putString("session_cookie", "boss_session=test")
.putString("restore_token", "restore-test")
.apply();
}
@Override
public ApiResponse autoLogin() throws java.io.IOException, org.json.JSONException {
autoLoginCalls += 1;
return ApiResponse.error(500, new JSONObject().put("ok", false).put("message", "AUTO_LOGIN_SHOULD_NOT_RUN"));
}
@Override
public ApiResponse getSession() throws java.io.IOException, org.json.JSONException {
getSessionCalls += 1;
return ApiResponse.error(401, new JSONObject().put("ok", false).put("message", "SESSION_EXPIRED"));
}
@Override
public ApiResponse restoreSession() throws java.io.IOException, org.json.JSONException {
restoreCalls += 1;
JSONObject session = new JSONObject()
.put("account", "krisolo")
.put("displayName", "Boss 超级管理员")
.put("restoreToken", "restore-test");
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("session", session));
}
@Override
public ApiResponse getConversationHome() throws java.io.IOException, org.json.JSONException {
homeCalls += 1;
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("conversations", new JSONArray().put(new JSONObject()
.put("projectId", "master-agent")
.put("conversationType", "master_agent")
.put("projectTitle", "主 Agent")
.put("threadTitle", "主 Agent 汇总")
.put("lastMessagePreview", "最近会话已恢复")
.put("latestReplyLabel", "刚刚"))));
}
@Override
public ApiResponse getDevices() throws java.io.IOException, org.json.JSONException {
devicesCalls += 1;
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("devices", new JSONArray()));
}
@Override
public ApiResponse getOtaStatus() throws java.io.IOException, org.json.JSONException {
otaCalls += 1;
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("hasOta", false));
}
@Override
public ApiResponse getSettings() throws java.io.IOException, org.json.JSONException {
settingsCalls += 1;
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("settings", new JSONObject().put("preferredEntryPoint", "conversations"))
.put("user", new JSONObject()));
}
}
}

View File

@@ -1,13 +1,21 @@
package com.hyzq.boss;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import android.Manifest;
import android.content.Context;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.Shadows;
import org.robolectric.annotation.Config;
import org.robolectric.shadows.ShadowApplication;
import org.robolectric.util.ReflectionHelpers;
@RunWith(RobolectricTestRunner.class)
@@ -18,6 +26,10 @@ public class MainActivityConversationAutoRefreshTest {
org.robolectric.android.controller.ActivityController<MainActivity> controller =
Robolectric.buildActivity(MainActivity.class).setup().resume();
MainActivity activity = controller.get();
activity.getSharedPreferences("boss_native_client", Context.MODE_PRIVATE)
.edit()
.putString("session_cookie", "boss_session=test")
.apply();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
assertTrue(ReflectionHelpers.getField(activity, "conversationAutoRefreshArmed"));
@@ -35,4 +47,53 @@ public class MainActivityConversationAutoRefreshTest {
controller.pause();
assertFalse(ReflectionHelpers.getField(activity, "conversationAutoRefreshArmed"));
}
@Test
public void returningToVisibleConversationRootRefreshesImmediatelyOnResume() {
org.robolectric.android.controller.ActivityController<TestResumeRefreshMainActivity> controller =
Robolectric.buildActivity(TestResumeRefreshMainActivity.class).setup().resume();
TestResumeRefreshMainActivity activity = controller.get();
activity.getSharedPreferences("boss_native_client", Context.MODE_PRIVATE)
.edit()
.putString("session_cookie", "boss_session=test")
.apply();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
activity.conversationRefreshCount = 0;
controller.pause();
controller.resume();
assertEquals(1, activity.conversationRefreshCount);
}
@Test
public void showContent_doesNotRequestNotificationPermissionInSameTapFrame() {
ShadowApplication.getInstance().denyPermissions(Manifest.permission.POST_NOTIFICATIONS);
org.robolectric.android.controller.ActivityController<MainActivity> controller =
Robolectric.buildActivity(MainActivity.class).setup();
MainActivity activity = controller.get();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
assertNull(Shadows.shadowOf(activity).getLastRequestedPermission());
Shadows.shadowOf(activity.getMainLooper()).idleFor(java.time.Duration.ofMillis(500));
assertNotNull(Shadows.shadowOf(activity).getLastRequestedPermission());
assertEquals(1, Shadows.shadowOf(activity).getLastRequestedPermission().requestedPermissions.length);
assertEquals(
Manifest.permission.POST_NOTIFICATIONS,
Shadows.shadowOf(activity).getLastRequestedPermission().requestedPermissions[0]
);
}
public static class TestResumeRefreshMainActivity extends MainActivity {
int conversationRefreshCount;
@Override
void refreshConversationsData() {
conversationRefreshCount += 1;
completeRealtimeTabRefresh();
}
}
}

View File

@@ -7,6 +7,7 @@ import static org.junit.Assert.assertTrue;
import android.content.Context;
import android.content.Intent;
import android.Manifest;
import android.view.View;
import android.view.inputmethod.InputMethodManager;
import android.widget.EditText;
@@ -25,6 +26,7 @@ import org.robolectric.annotation.Config;
import org.robolectric.Shadows;
import org.robolectric.shadow.api.Shadow;
import org.robolectric.shadows.ShadowInputMethodManager;
import org.robolectric.shadows.ShadowApplication;
import org.robolectric.util.ReflectionHelpers;
@RunWith(RobolectricTestRunner.class)
@@ -148,6 +150,7 @@ public class MainActivityConversationSearchTest {
@Test
public void searchHitOnSingleThread_exitsSearchModeAndOpensProjectDetail() throws Exception {
ShadowApplication.getInstance().grantPermissions(Manifest.permission.POST_NOTIFICATIONS);
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.setField(activity, "conversationsData", buildConversations());
ReflectionHelpers.callInstanceMethod(activity, "showContent");
@@ -180,6 +183,7 @@ public class MainActivityConversationSearchTest {
@Test
public void searchHitInsideArchivedProject_opensMatchedThreadDetailAndClearsSearchState() throws Exception {
ShadowApplication.getInstance().grantPermissions(Manifest.permission.POST_NOTIFICATIONS);
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.setField(activity, "conversationsData", new JSONArray()
.put(new JSONObject()
@@ -221,6 +225,7 @@ public class MainActivityConversationSearchTest {
@Test
public void archivedProjectSearchByFolderName_stillOpensFolderPage() throws Exception {
ShadowApplication.getInstance().grantPermissions(Manifest.permission.POST_NOTIFICATIONS);
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.setField(activity, "conversationsData", new JSONArray()
.put(new JSONObject()

View File

@@ -90,6 +90,7 @@ public class MainActivityConversationSelectionTest {
public void topPlusAction_opensWechatStyleDropdownMenu() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.setField(activity, "conversationsData", buildConversations());
ReflectionHelpers.setField(activity, "sessionData", new JSONObject().put("role", "highest_admin"));
ReflectionHelpers.callInstanceMethod(activity, "showContent");
Shadows.shadowOf(activity.getMainLooper()).idle();
@@ -106,6 +107,27 @@ public class MainActivityConversationSelectionTest {
assertTrue(viewTreeContainsText(menu, "发起群聊"));
}
@Test
public void topPlusAction_hidesAddDeviceForSubAccount() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().get();
ReflectionHelpers.setField(activity, "conversationsData", buildConversations());
ReflectionHelpers.setField(activity, "sessionData", new JSONObject().put("role", "member"));
ReflectionHelpers.callInstanceMethod(activity, "showContent");
Shadows.shadowOf(activity.getMainLooper()).idle();
ImageButton actionButton = activity.findViewById(R.id.refresh_button);
actionButton.performClick();
Shadows.shadowOf(activity.getMainLooper()).idle();
View overlay = activity.findViewById(R.id.conversation_quick_actions_overlay);
View menu = activity.findViewById(R.id.conversation_quick_actions_menu);
assertEquals(View.VISIBLE, overlay.getVisibility());
assertEquals(View.VISIBLE, menu.getVisibility());
assertFalse(viewTreeContainsVisibleText(menu, "添加设备"));
assertTrue(viewTreeContainsVisibleText(menu, "扫一扫"));
assertTrue(viewTreeContainsVisibleText(menu, "发起群聊"));
}
private static View getRecyclerChild(RecyclerView recyclerView, int position) {
RecyclerView.Adapter adapter = recyclerView.getAdapter();
int viewType = adapter.getItemViewType(position);
@@ -188,6 +210,28 @@ public class MainActivityConversationSelectionTest {
return false;
}
private static boolean viewTreeContainsVisibleText(View root, String expectedText) {
if (root.getVisibility() != View.VISIBLE) {
return false;
}
if (root instanceof TextView) {
CharSequence text = ((TextView) root).getText();
if (expectedText.contentEquals(text)) {
return true;
}
}
if (!(root instanceof LinearLayout)) {
return false;
}
LinearLayout group = (LinearLayout) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
if (viewTreeContainsVisibleText(group.getChildAt(index), expectedText)) {
return true;
}
}
return false;
}
private static boolean viewTreeContainsContentDescription(View root, String expectedText) {
CharSequence description = root.getContentDescription();
if (expectedText.contentEquals(description)) {

View File

@@ -31,7 +31,7 @@ public class MainActivityDevicesRootTest {
.put("name", "Mac Studio")
.put("status", "online")
.put("platform", "macOS")
.put("account", "17600003315")));
.put("account", "krisolo")));
ReflectionHelpers.callInstanceMethod(activity, "showContent");
ReflectionHelpers.callInstanceMethod(activity, "setActiveTab",

View File

@@ -3,6 +3,7 @@ package com.hyzq.boss;
import static org.junit.Assert.assertEquals;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.Looper;
import org.json.JSONObject;
import org.json.JSONArray;
@@ -15,6 +16,7 @@ import org.robolectric.annotation.Config;
import org.robolectric.util.ReflectionHelpers;
import java.io.IOException;
import java.time.Duration;
import java.util.function.BooleanSupplier;
@RunWith(RobolectricTestRunner.class)
@@ -24,6 +26,15 @@ public class MainActivityRealtimeTest {
public void conversationRealtimeEventRefreshesVisibleConversationTab() throws Exception {
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
ReflectionHelpers.callInstanceMethod(
activity,
"setActiveTab",
ReflectionHelpers.ClassParameter.from(String.class, "conversations"),
ReflectionHelpers.ClassParameter.from(boolean.class, false)
);
activity.conversationRefreshCount = 0;
activity.deviceRefreshCount = 0;
activity.meRefreshCount = 0;
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
@@ -33,6 +44,8 @@ public class MainActivityRealtimeTest {
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(0, activity.conversationRefreshCount);
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
assertEquals(1, activity.conversationRefreshCount);
assertEquals(0, activity.deviceRefreshCount);
@@ -78,6 +91,15 @@ public class MainActivityRealtimeTest {
public void deviceScopedConversationEventRefreshesVisibleConversationTab() throws Exception {
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
ReflectionHelpers.callInstanceMethod(
activity,
"setActiveTab",
ReflectionHelpers.ClassParameter.from(String.class, "conversations"),
ReflectionHelpers.ClassParameter.from(boolean.class, false)
);
activity.conversationRefreshCount = 0;
activity.deviceRefreshCount = 0;
activity.meRefreshCount = 0;
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
@@ -87,6 +109,8 @@ public class MainActivityRealtimeTest {
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(0, activity.conversationRefreshCount);
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
assertEquals(1, activity.conversationRefreshCount);
assertEquals(0, activity.deviceRefreshCount);
@@ -108,6 +132,8 @@ public class MainActivityRealtimeTest {
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(0, activity.conversationRefreshCount);
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
assertEquals(1, activity.conversationRefreshCount);
assertEquals(0, activity.deviceRefreshCount);
@@ -129,13 +155,15 @@ public class MainActivityRealtimeTest {
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(0, activity.conversationRefreshCount);
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
assertEquals(1, activity.conversationRefreshCount);
assertEquals(0, activity.deviceRefreshCount);
}
@Test
public void distinctConversationEventsBackToBackBothRefreshVisibleConversationTab() throws Exception {
public void distinctConversationEventsBackToBackCoalesceIntoSingleVisibleConversationRefresh() throws Exception {
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
ReflectionHelpers.callInstanceMethod(
@@ -161,8 +189,10 @@ public class MainActivityRealtimeTest {
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(0, activity.conversationRefreshCount);
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
assertEquals(2, activity.conversationRefreshCount);
assertEquals(1, activity.conversationRefreshCount);
assertEquals(0, activity.deviceRefreshCount);
}
@@ -176,6 +206,9 @@ public class MainActivityRealtimeTest {
ReflectionHelpers.ClassParameter.from(String.class, "devices"),
ReflectionHelpers.ClassParameter.from(boolean.class, false)
);
activity.conversationRefreshCount = 0;
activity.deviceRefreshCount = 0;
activity.meRefreshCount = 0;
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
@@ -187,6 +220,8 @@ public class MainActivityRealtimeTest {
Shadows.shadowOf(activity.getMainLooper()).idle();
assertEquals(0, activity.conversationRefreshCount);
assertEquals(0, activity.deviceRefreshCount);
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
assertEquals(1, activity.deviceRefreshCount);
assertEquals(0, activity.meRefreshCount);
}
@@ -201,6 +236,9 @@ public class MainActivityRealtimeTest {
ReflectionHelpers.ClassParameter.from(String.class, "me"),
ReflectionHelpers.ClassParameter.from(boolean.class, false)
);
activity.conversationRefreshCount = 0;
activity.deviceRefreshCount = 0;
activity.meRefreshCount = 0;
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
@@ -213,6 +251,8 @@ public class MainActivityRealtimeTest {
assertEquals(0, activity.conversationRefreshCount);
assertEquals(0, activity.deviceRefreshCount);
assertEquals(0, activity.meRefreshCount);
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
assertEquals(1, activity.meRefreshCount);
}
@@ -220,6 +260,15 @@ public class MainActivityRealtimeTest {
public void burstConversationRealtimeEventsCoalesceIntoSingleFollowUpRefresh() throws Exception {
TestMainActivity activity = Robolectric.buildActivity(TestMainActivity.class).setup().resume().get();
ReflectionHelpers.callInstanceMethod(activity, "showContent");
ReflectionHelpers.callInstanceMethod(
activity,
"setActiveTab",
ReflectionHelpers.ClassParameter.from(String.class, "conversations"),
ReflectionHelpers.ClassParameter.from(boolean.class, false)
);
activity.conversationRefreshCount = 0;
activity.deviceRefreshCount = 0;
activity.meRefreshCount = 0;
ReflectionHelpers.setField(activity, "rootTabRefreshInFlight", true);
ReflectionHelpers.callInstanceMethod(
@@ -253,6 +302,7 @@ public class MainActivityRealtimeTest {
assertEquals(0, activity.conversationRefreshCount);
activity.completeRealtimeTabRefresh();
Shadows.shadowOf(activity.getMainLooper()).idleFor(Duration.ofMillis(400));
waitFor(() -> activity.conversationRefreshCount == 1);
assertEquals(1, activity.conversationRefreshCount);
@@ -282,7 +332,7 @@ public class MainActivityRealtimeTest {
}
@Test
public void refreshConversationsData_prefersConversationHomeFeedOverFlatConversationsFeed() throws Exception {
public void refreshConversationsData_prefersGroupedHomeFeedOverFlatConversationFeed() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
Shadows.shadowOf(activity.getMainLooper()).idle();
@@ -294,18 +344,47 @@ public class MainActivityRealtimeTest {
Shadows.shadowOf(activity.getMainLooper()).idle();
activity.refreshConversationsData();
waitFor(() -> apiClient.homeCalls > 0 || apiClient.conversationsCalls > 0);
waitFor(() -> apiClient.homeCalls > 0);
assertEquals(1, apiClient.homeCalls);
assertEquals(0, apiClient.conversationsCalls);
}
@Test
public void refreshConversationsData_prefersGroupedHomeFeedForRootList() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
Shadows.shadowOf(activity.getMainLooper()).idle();
SharedPreferences prefs = activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE);
prefs.edit()
.putString("session_cookie", "boss_session=test")
.putString("restore_token", "restore-test")
.apply();
RecordingConversationSourceClient apiClient = new RecordingConversationSourceClient(
prefs
);
ReflectionHelpers.setField(activity, "apiClient", apiClient);
ReflectionHelpers.callInstanceMethod(activity, "showContent");
Shadows.shadowOf(activity.getMainLooper()).idle();
activity.refreshConversationsData();
waitFor(() -> apiClient.homeCalls > 0);
assertEquals(1, apiClient.homeCalls);
assertEquals(0, apiClient.conversationsCalls);
JSONArray conversationsData = ReflectionHelpers.getField(activity, "conversationsData");
assertEquals(1, conversationsData.length());
assertEquals("folder_archive", conversationsData.optJSONObject(0).optString("conversationType", ""));
assertEquals("mac-studio:boss", conversationsData.optJSONObject(0).optString("projectId", ""));
assertEquals(2, conversationsData.optJSONObject(0).optInt("threadCount", 0));
}
@Test
public void refreshConversationsData_groupsFlatFallbackFeedWhenHomeFeedFails() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
Shadows.shadowOf(activity.getMainLooper()).idle();
RecordingRejectedConversationSourceClient apiClient = new RecordingRejectedConversationSourceClient(
RecordingRejectedHomeConversationSourceClient apiClient = new RecordingRejectedHomeConversationSourceClient(
activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE)
);
ReflectionHelpers.setField(activity, "apiClient", apiClient);
@@ -328,7 +407,7 @@ public class MainActivityRealtimeTest {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
Shadows.shadowOf(activity.getMainLooper()).idle();
RecordingIOExceptionConversationSourceClient apiClient = new RecordingIOExceptionConversationSourceClient(
RecordingIOExceptionHomeConversationSourceClient apiClient = new RecordingIOExceptionHomeConversationSourceClient(
activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE)
);
ReflectionHelpers.setField(activity, "apiClient", apiClient);
@@ -347,7 +426,7 @@ public class MainActivityRealtimeTest {
}
@Test
public void refreshAllData_prefersConversationHomeFeedOverFlatConversationsFeed() throws Exception {
public void refreshAllData_prefersGroupedHomeFeedOverFlatConversationFeed() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
Shadows.shadowOf(activity.getMainLooper()).idle();
@@ -363,18 +442,51 @@ public class MainActivityRealtimeTest {
"refreshAllData",
ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject())
);
waitFor(() -> apiClient.homeCalls > 0 || apiClient.conversationsCalls > 0);
waitFor(() -> apiClient.homeCalls > 0);
assertEquals(1, apiClient.homeCalls);
assertEquals(0, apiClient.conversationsCalls);
}
@Test
public void refreshAllData_prefersGroupedHomeFeedForRootList() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
Shadows.shadowOf(activity.getMainLooper()).idle();
SharedPreferences prefs = activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE);
prefs.edit()
.putString("session_cookie", "boss_session=test")
.putString("restore_token", "restore-test")
.apply();
RecordingConversationSourceClient apiClient = new RecordingConversationSourceClient(
prefs
);
ReflectionHelpers.setField(activity, "apiClient", apiClient);
ReflectionHelpers.callInstanceMethod(activity, "showContent");
Shadows.shadowOf(activity.getMainLooper()).idle();
ReflectionHelpers.callInstanceMethod(
activity,
"refreshAllData",
ReflectionHelpers.ClassParameter.from(JSONObject.class, new JSONObject())
);
waitFor(() -> apiClient.homeCalls > 0);
assertEquals(1, apiClient.homeCalls);
assertEquals(0, apiClient.conversationsCalls);
JSONArray conversationsData = ReflectionHelpers.getField(activity, "conversationsData");
assertEquals(1, conversationsData.length());
assertEquals("folder_archive", conversationsData.optJSONObject(0).optString("conversationType", ""));
assertEquals("mac-studio:boss", conversationsData.optJSONObject(0).optString("projectId", ""));
assertEquals(2, conversationsData.optJSONObject(0).optInt("threadCount", 0));
}
@Test
public void refreshAllData_groupsFlatFallbackFeedWhenHomeFeedFails() throws Exception {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
Shadows.shadowOf(activity.getMainLooper()).idle();
RecordingRejectedConversationSourceClient apiClient = new RecordingRejectedConversationSourceClient(
RecordingRejectedHomeConversationSourceClient apiClient = new RecordingRejectedHomeConversationSourceClient(
activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE)
);
ReflectionHelpers.setField(activity, "apiClient", apiClient);
@@ -401,7 +513,7 @@ public class MainActivityRealtimeTest {
MainActivity activity = Robolectric.buildActivity(MainActivity.class).setup().resume().get();
Shadows.shadowOf(activity.getMainLooper()).idle();
RecordingIOExceptionConversationSourceClient apiClient = new RecordingIOExceptionConversationSourceClient(
RecordingIOExceptionHomeConversationSourceClient apiClient = new RecordingIOExceptionHomeConversationSourceClient(
activity.getSharedPreferences("test-boss-api", Context.MODE_PRIVATE)
);
ReflectionHelpers.setField(activity, "apiClient", apiClient);
@@ -445,6 +557,13 @@ public class MainActivityRealtimeTest {
int deviceRefreshCount;
int meRefreshCount;
@Override
BossApiClient createApiClient() {
SharedPreferences prefs = getSharedPreferences("boss_native_client", Context.MODE_PRIVATE);
prefs.edit().clear().apply();
return new InertBootstrapApiClient(prefs);
}
@Override
void refreshConversationsData() {
conversationRefreshCount += 1;
@@ -464,7 +583,28 @@ public class MainActivityRealtimeTest {
}
}
private static final class RecordingRejectedConversationSourceClient extends BossApiClient {
private static final class InertBootstrapApiClient extends BossApiClient {
InertBootstrapApiClient(SharedPreferences prefs) {
super(prefs, "https://boss.hyzq.net");
}
@Override
public ApiResponse autoLogin() throws IOException, org.json.JSONException {
return ApiResponse.error(401, new JSONObject().put("ok", false).put("message", "TEST_BOOTSTRAP_DISABLED"));
}
@Override
public ApiResponse restoreSession() throws IOException, org.json.JSONException {
return ApiResponse.error(401, new JSONObject().put("ok", false).put("message", "TEST_BOOTSTRAP_DISABLED"));
}
@Override
public ApiResponse getSession() throws IOException, org.json.JSONException {
return ApiResponse.error(401, new JSONObject().put("ok", false).put("message", "TEST_BOOTSTRAP_DISABLED"));
}
}
private static final class RecordingRejectedHomeConversationSourceClient extends BossApiClient {
int homeCalls;
int conversationsCalls;
int sessionCalls;
@@ -472,7 +612,7 @@ public class MainActivityRealtimeTest {
int settingsCalls;
int otaCalls;
RecordingRejectedConversationSourceClient(android.content.SharedPreferences prefs) {
RecordingRejectedHomeConversationSourceClient(android.content.SharedPreferences prefs) {
super(prefs, "https://boss.hyzq.net");
}
@@ -489,7 +629,7 @@ public class MainActivityRealtimeTest {
conversationsCalls += 1;
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("conversations", buildFlatConversations()));
.put("conversations", RecordingConversationSourceClient.buildFlatConversations()));
}
@Override
@@ -498,7 +638,7 @@ public class MainActivityRealtimeTest {
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("session", new JSONObject()
.put("account", "17600003315")
.put("account", "krisolo")
.put("displayName", "Boss 超级管理员")));
}
@@ -526,32 +666,6 @@ public class MainActivityRealtimeTest {
.put("ok", true)
.put("hasOta", false));
}
private static JSONArray buildFlatConversations() throws org.json.JSONException {
return new JSONArray()
.put(new JSONObject()
.put("projectId", "thread-revert")
.put("conversationType", "single_device")
.put("projectTitle", "发布回滚")
.put("threadTitle", "发布回滚")
.put("folderLabel", "Boss")
.put("folderKey", "mac-studio:boss")
.put("lastMessagePreview", "最近:发布回滚")
.put("latestReplyAt", "2026-04-06T10:00:00.000Z")
.put("latestReplyLabel", "11:00")
.put("contextBudgetIndicator", new JSONObject().put("visible", true).put("style", "ring_percent").put("percent", 80).put("level", "watch")))
.put(new JSONObject()
.put("projectId", "thread-ui")
.put("conversationType", "single_device")
.put("projectTitle", "Android UI 收尾")
.put("threadTitle", "Android UI 收尾")
.put("folderLabel", "Boss")
.put("folderKey", "mac-studio:boss")
.put("lastMessagePreview", "最近Android UI 收尾")
.put("latestReplyAt", "2026-04-06T09:59:00.000Z")
.put("latestReplyLabel", "10:59")
.put("contextBudgetIndicator", new JSONObject().put("visible", true).put("style", "ring_percent").put("percent", 95).put("level", "safe")));
}
}
private static final class RecordingConversationSourceClient extends BossApiClient {
@@ -588,7 +702,7 @@ public class MainActivityRealtimeTest {
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("session", new JSONObject()
.put("account", "17600003315")
.put("account", "krisolo")
.put("displayName", "Boss 超级管理员")));
}
@@ -619,13 +733,15 @@ public class MainActivityRealtimeTest {
private static JSONArray buildHomeConversations() throws org.json.JSONException {
return new JSONArray().put(new JSONObject()
.put("projectId", "folder-boss")
.put("projectId", "mac-studio:boss")
.put("conversationType", "folder_archive")
.put("folderKey", "mac-studio:boss")
.put("projectTitle", "Boss")
.put("threadTitle", "Boss")
.put("threadCount", 2)
.put("folderLabel", "2 个线程 · 最近:发布回滚")
.put("searchAliases", new JSONArray().put("发布回滚").put("Android UI 收尾"))
.put("searchTargetProjectIds", new JSONArray().put("thread-revert").put("thread-ui"))
.put("lastMessagePreview", "最近:发布回滚")
.put("latestReplyLabel", "11:00"));
}
@@ -657,7 +773,7 @@ public class MainActivityRealtimeTest {
}
}
private static final class RecordingIOExceptionConversationSourceClient extends BossApiClient {
private static final class RecordingIOExceptionHomeConversationSourceClient extends BossApiClient {
int homeCalls;
int conversationsCalls;
int sessionCalls;
@@ -665,7 +781,7 @@ public class MainActivityRealtimeTest {
int settingsCalls;
int otaCalls;
RecordingIOExceptionConversationSourceClient(android.content.SharedPreferences prefs) {
RecordingIOExceptionHomeConversationSourceClient(android.content.SharedPreferences prefs) {
super(prefs, "https://boss.hyzq.net");
}
@@ -680,7 +796,7 @@ public class MainActivityRealtimeTest {
conversationsCalls += 1;
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("conversations", buildFlatConversations()));
.put("conversations", RecordingConversationSourceClient.buildFlatConversations()));
}
@Override
@@ -689,7 +805,7 @@ public class MainActivityRealtimeTest {
return new ApiResponse(200, new JSONObject()
.put("ok", true)
.put("session", new JSONObject()
.put("account", "17600003315")
.put("account", "krisolo")
.put("displayName", "Boss 超级管理员")));
}
@@ -717,31 +833,5 @@ public class MainActivityRealtimeTest {
.put("ok", true)
.put("hasOta", false));
}
private static JSONArray buildFlatConversations() throws org.json.JSONException {
return new JSONArray()
.put(new JSONObject()
.put("projectId", "thread-revert")
.put("conversationType", "single_device")
.put("projectTitle", "发布回滚")
.put("threadTitle", "发布回滚")
.put("folderLabel", "Boss")
.put("folderKey", "mac-studio:boss")
.put("lastMessagePreview", "最近:发布回滚")
.put("latestReplyAt", "2026-04-06T10:00:00.000Z")
.put("latestReplyLabel", "11:00")
.put("contextBudgetIndicator", new JSONObject().put("visible", true).put("style", "ring_percent").put("percent", 80).put("level", "watch")))
.put(new JSONObject()
.put("projectId", "thread-ui")
.put("conversationType", "single_device")
.put("projectTitle", "Android UI 收尾")
.put("threadTitle", "Android UI 收尾")
.put("folderLabel", "Boss")
.put("folderKey", "mac-studio:boss")
.put("lastMessagePreview", "最近Android UI 收尾")
.put("latestReplyAt", "2026-04-06T09:59:00.000Z")
.put("latestReplyLabel", "10:59")
.put("contextBudgetIndicator", new JSONObject().put("visible", true).put("style", "ring_percent").put("percent", 95).put("level", "safe")));
}
}
}

View File

@@ -176,7 +176,7 @@ public class MasterAgentTakeoverActivityTest {
200,
new JSONObject()
.put("ok", true)
.put("session", new JSONObject().put("account", "17600003315"))
.put("session", new JSONObject().put("account", "krisolo"))
);
}

View File

@@ -59,6 +59,14 @@ public class ProjectChatUiStateTest {
assertTrue(ProjectChatUiState.canForwardSelection(next));
}
@Test
public void copySelectionRequiresAtLeastOneMessage() {
assertFalse(ProjectChatUiState.canCopySelection(ProjectChatUiState.emptySelection()));
ProjectChatUiState.SelectionState state = ProjectChatUiState.toggleSelection(null, "m1");
assertTrue(ProjectChatUiState.canCopySelection(state));
}
@Test
public void selectionPreservesInsertionOrder() {
ProjectChatUiState.SelectionState state = ProjectChatUiState.toggleSelection(null, "m2");
@@ -104,6 +112,7 @@ public class ProjectChatUiStateTest {
assertTrue(chromeState.showMultiSelectBar);
assertFalse(chromeState.showRefresh);
assertFalse(chromeState.showHeaderAction);
assertTrue(chromeState.copyEnabled);
assertTrue(chromeState.forwardEnabled);
assertEquals("取消", chromeState.backLabel);
assertEquals("已选 2 条", chromeState.title);
@@ -120,6 +129,7 @@ public class ProjectChatUiStateTest {
assertFalse(chromeState.showMultiSelectBar);
assertFalse(chromeState.showRefresh);
assertTrue(chromeState.showHeaderAction);
assertFalse(chromeState.copyEnabled);
assertFalse(chromeState.forwardEnabled);
assertEquals("返回", chromeState.backLabel);
assertEquals("北区试产线回归", chromeState.title);
@@ -136,6 +146,7 @@ public class ProjectChatUiStateTest {
assertFalse(chromeState.showMultiSelectBar);
assertTrue(chromeState.showRefresh);
assertFalse(chromeState.showHeaderAction);
assertFalse(chromeState.copyEnabled);
assertFalse(chromeState.forwardEnabled);
assertEquals("返回", chromeState.backLabel);
assertEquals("北区试产线回归", chromeState.title);
@@ -196,9 +207,10 @@ public class ProjectChatUiStateTest {
}
@Test
public void queuedReplyTaskStartsReplyWaitFromRequestMessageId() throws Exception {
public void queuedReplyTaskStartsReplyWaitFromImmediateReplyWhenPresent() throws Exception {
JSONObject response = new JSONObject()
.put("message", new JSONObject().put("id", "msg-user-1"))
.put("replyMessage", new JSONObject().put("id", "msg-master-ack-1"))
.put("task", new JSONObject()
.put("taskId", "task-1")
.put("taskType", "conversation_reply")
@@ -207,7 +219,7 @@ public class ProjectChatUiStateTest {
ProjectChatUiState.ReplyWaitSpec waitSpec = ProjectChatUiState.resolveReplyWaitAfterSend(response);
assertTrue(waitSpec.shouldWait);
assertEquals("msg-user-1", waitSpec.baselineMessageId);
assertEquals("msg-master-ack-1", waitSpec.baselineMessageId);
}
@Test
@@ -250,6 +262,318 @@ public class ProjectChatUiStateTest {
assertFalse(ProjectChatUiState.hasReplyBeyondBaseline(project, ""));
}
@Test
public void replyWaitIgnoresDuplicateBaselineMessages() throws Exception {
JSONObject project = new JSONObject()
.put("messages", new JSONArray()
.put(new JSONObject().put("id", "msg-user-1"))
.put(new JSONObject().put("id", "msg-user-1")));
assertFalse(ProjectChatUiState.hasReplyBeyondBaseline(project, "msg-user-1"));
}
@Test
public void timedOutMasterRelayKeepsConversationPollingEvenWhenRealtimeConnected() {
assertTrue(ProjectChatUiState.shouldAutoRefreshConversation(true, true, true));
assertTrue(ProjectChatUiState.shouldAutoRefreshConversation(true, false, false));
assertFalse(ProjectChatUiState.shouldAutoRefreshConversation(true, true, false));
assertFalse(ProjectChatUiState.shouldAutoRefreshConversation(false, true, true));
}
@Test
public void threadProcessMessagesAreCollapsedBeforeFinalResult() throws Exception {
JSONArray messages = new JSONArray()
.put(new JSONObject()
.put("id", "u1")
.put("sender", "user")
.put("body", "继续"))
.put(new JSONObject()
.put("id", "p1")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("body", "我先看一下当前聊天渲染链路和消息结构。"))
.put(new JSONObject()
.put("id", "p2")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("body", "接下来我会补一组单元测试,再把折叠 UI 接上。"))
.put(new JSONObject()
.put("id", "r1")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("body", "这轮已经接好过程折叠,最终结果现在直接显示在主消息流里。"));
List<ProjectChatUiState.MessageDisplayItem> items =
ProjectChatUiState.buildMessageDisplayItems(messages);
assertEquals(3, items.size());
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(0).type);
assertEquals("u1", items.get(0).message.optString("id"));
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(1).type);
assertEquals(2, items.get(1).processMessages.size());
assertEquals("p1", items.get(1).processMessages.get(0).optString("id"));
assertEquals("p2", items.get(1).processMessages.get(1).optString("id"));
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(2).type);
assertEquals("r1", items.get(2).message.optString("id"));
}
@Test
public void errorMessagesStayVisibleInsteadOfBeingCollapsed() throws Exception {
JSONArray messages = new JSONArray()
.put(new JSONObject()
.put("id", "e1")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("body", "当前执行失败,构建报错,需要先补依赖。"));
List<ProjectChatUiState.MessageDisplayItem> items =
ProjectChatUiState.buildMessageDisplayItems(messages);
assertEquals(1, items.size());
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(0).type);
assertEquals("e1", items.get(0).message.optString("id"));
}
@Test
public void processGroupPreviewUsesLatestProgressLine() throws Exception {
JSONArray messages = new JSONArray()
.put(new JSONObject()
.put("id", "p1")
.put("sender", "device")
.put("body", "我先检查项目结构。"))
.put(new JSONObject()
.put("id", "p2")
.put("sender", "device")
.put("body", "接下来开始补聊天折叠按钮。"));
List<ProjectChatUiState.MessageDisplayItem> items =
ProjectChatUiState.buildMessageDisplayItems(messages);
assertEquals(1, items.size());
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(0).type);
assertEquals("接下来开始补聊天折叠按钮。", ProjectChatUiState.processGroupPreview(items.get(0)));
}
@Test
public void explicitThreadProcessKindIsCollapsedEvenWhenCopyLooksLikeACompletionUpdate() throws Exception {
JSONArray messages = new JSONArray()
.put(new JSONObject()
.put("id", "p1")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("kind", "thread_process")
.put("body", "工程骨架已经建好了,我现在开始写核心代码。"))
.put(new JSONObject()
.put("id", "p2")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("kind", "thread_process")
.put("body", "编译错误已定位到导入问题,我已修复并正在重新构建确认。"))
.put(new JSONObject()
.put("id", "r1")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("kind", "text")
.put("body", "已完成折叠修复,过程消息会收进按钮里,未读只增加一次。"));
List<ProjectChatUiState.MessageDisplayItem> items =
ProjectChatUiState.buildMessageDisplayItems(messages);
assertEquals(2, items.size());
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(0).type);
assertEquals(2, items.get(0).processMessages.size());
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(1).type);
assertEquals("r1", items.get(1).message.optString("id"));
}
@Test
public void executionProgressCardsStayVisibleBetweenProcessGroups() throws Exception {
JSONArray messages = new JSONArray()
.put(new JSONObject()
.put("id", "p1")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("kind", "thread_process")
.put("body", "我先检查当前执行链路。"))
.put(new JSONObject()
.put("id", "progress-1")
.put("sender", "master")
.put("senderLabel", "主 Agent")
.put("kind", "execution_progress")
.put("body", "执行进度")
.put("executionProgress", new JSONObject()
.put("status", "running")
.put("steps", new JSONArray()
.put(new JSONObject().put("text", "接收对话任务").put("status", "done"))
.put(new JSONObject().put("text", "等待目标线程回复").put("status", "running")))))
.put(new JSONObject()
.put("id", "p2")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("kind", "thread_process")
.put("body", "我继续执行验证。"));
List<ProjectChatUiState.MessageDisplayItem> items =
ProjectChatUiState.buildMessageDisplayItems(messages);
assertEquals(3, items.size());
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(0).type);
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(1).type);
assertEquals("progress-1", items.get(1).message.optString("id"));
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(2).type);
}
@Test
public void processGroupKeepsFinalResultVisibleWhenProcessMessagesCarryThreadProcessKind() throws Exception {
JSONArray messages = new JSONArray()
.put(new JSONObject()
.put("id", "u1")
.put("sender", "user")
.put("body", "继续推进"))
.put(new JSONObject()
.put("id", "p1")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("kind", "thread_process")
.put("body", "我先检查聊天折叠链路,确认过程消息不会直接展开。"))
.put(new JSONObject()
.put("id", "r1")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("kind", "text")
.put("body", "这轮已经完成折叠修复,未读现在只会算最终结果。"));
List<ProjectChatUiState.MessageDisplayItem> items =
ProjectChatUiState.buildMessageDisplayItems(messages);
assertEquals(3, items.size());
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(0).type);
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(1).type);
assertEquals(1, items.get(1).processMessages.size());
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(2).type);
assertEquals("r1", items.get(2).message.optString("id"));
}
@Test
public void numberedProgressUpdatesAreCollapsedWhenMarkedAsThreadProcess() throws Exception {
JSONArray messages = new JSONArray()
.put(new JSONObject()
.put("id", "u1")
.put("sender", "user")
.put("body", "继续处理"))
.put(new JSONObject()
.put("id", "p1")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("kind", "thread_process")
.put("body", "1. 先检查当前消息折叠链路。\\n2. 再确认 Android 端只把最终结果记成未读。\\n3. 处理完成后我会回你最终结果。"))
.put(new JSONObject()
.put("id", "r1")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("kind", "text")
.put("body", "这轮已经处理完成,最终结果已回写。"));
List<ProjectChatUiState.MessageDisplayItem> items =
ProjectChatUiState.buildMessageDisplayItems(messages);
assertEquals(3, items.size());
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(0).type);
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(1).type);
assertEquals(1, items.get(1).processMessages.size());
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(2).type);
assertEquals("r1", items.get(2).message.optString("id"));
}
@Test
public void numberedProgressUpdatesWithoutKindStillCollapseBeforeFinalResult() throws Exception {
JSONArray messages = new JSONArray()
.put(new JSONObject()
.put("id", "u1")
.put("sender", "user")
.put("body", "继续处理"))
.put(new JSONObject()
.put("id", "p1")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("body", "1. 先检查当前消息折叠链路。\n2. 再确认 Android 端只把最终结果记成未读。\n3. 处理完成后我会回你最终结果。"))
.put(new JSONObject()
.put("id", "r1")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("body", "这轮已经处理完成,最终结果已回写。"));
List<ProjectChatUiState.MessageDisplayItem> items =
ProjectChatUiState.buildMessageDisplayItems(messages);
assertEquals(3, items.size());
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(0).type);
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(1).type);
assertEquals(1, items.get(1).processMessages.size());
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(2).type);
assertEquals("r1", items.get(2).message.optString("id"));
}
@Test
public void progressUpdatesStartingWithWoZheBianYiJingStillCollapseIntoProcessGroup() throws Exception {
JSONArray messages = new JSONArray()
.put(new JSONObject()
.put("id", "u1")
.put("sender", "user")
.put("body", "继续"))
.put(new JSONObject()
.put("id", "p1")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("body", "我这边已经查了adb 现在还只看到一台 USB 连着的 PHZ110PLB110 的无线目标还没有被发现出来。"))
.put(new JSONObject()
.put("id", "r1")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程")
.put("kind", "text")
.put("body", "无线调试已经接通,最新 debug 包也装好了。"));
List<ProjectChatUiState.MessageDisplayItem> items =
ProjectChatUiState.buildMessageDisplayItems(messages);
assertEquals(3, items.size());
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(0).type);
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(1).type);
assertEquals(1, items.get(1).processMessages.size());
assertEquals("p1", items.get(1).processMessages.get(0).optString("id"));
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(2).type);
assertEquals("r1", items.get(2).message.optString("id"));
}
@Test
public void realThreadPlanningCopyIsCollapsedButSavedResultStaysVisible() throws Exception {
JSONArray messages = new JSONArray()
.put(new JSONObject()
.put("id", "p1")
.put("sender", "device")
.put("senderLabel", "Andorid")
.put("body", "我发现当前这个仓库快照里没有 ios/ 目录,所以这份报告会明确分成两层。"))
.put(new JSONObject()
.put("id", "p2")
.put("sender", "device")
.put("senderLabel", "Andorid")
.put("body", "我准备新增一份 doc/iOS实时转写开发交接报告_20260419.md。"))
.put(new JSONObject()
.put("id", "r1")
.put("sender", "device")
.put("senderLabel", "Andorid")
.put("body", "报告已经落盘了。我再快速过一遍这份文档的结构和措辞。"));
List<ProjectChatUiState.MessageDisplayItem> items =
ProjectChatUiState.buildMessageDisplayItems(messages);
assertEquals(2, items.size());
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_PROCESS_GROUP, items.get(0).type);
assertEquals(2, items.get(0).processMessages.size());
assertEquals(ProjectChatUiState.MessageDisplayItem.TYPE_MESSAGE, items.get(1).type);
assertEquals("r1", items.get(1).message.optString("id"));
}
@Test
public void threadExecutionConflictCopyExplainsPreferredGuiModeAsProjectScoped() throws Exception {
JSONObject conflict = new JSONObject()

View File

@@ -19,6 +19,7 @@ import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.Shadows;
import org.robolectric.annotation.Config;
import org.robolectric.shadows.ShadowDialog;
import org.robolectric.util.ReflectionHelpers;
@@ -113,7 +114,7 @@ public class ProjectDetailActivityMasterAgentMenuTest {
}
@Test
public void normalConversationMoreMenuShowsInfoAndRefresh() {
public void normalConversationHeaderActionOpensConversationInfoDirectly() {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "Boss 移动控制台");
@@ -122,15 +123,11 @@ public class ProjectDetailActivityMasterAgentMenuTest {
.setup()
.get();
ReflectionHelpers.callInstanceMethod(activity, "showConversationMoreMenu");
ReflectionHelpers.callInstanceMethod(activity, "openConversationInfo");
android.app.Dialog latestDialog = ShadowDialog.getLatestDialog();
assertTrue(latestDialog instanceof AlertDialog);
AlertDialog actionDialog = (AlertDialog) latestDialog;
ListView listView = actionDialog.getListView();
assertMenuItem(listView, 0, "会话信息");
assertMenuItem(listView, 1, "刷新");
Intent nextIntent = Shadows.shadowOf(activity).getNextStartedActivity();
assertNotNull(nextIntent);
assertEquals(ConversationInfoActivity.class.getName(), nextIntent.getComponent().getClassName());
}
@Test

View File

@@ -1,11 +1,19 @@
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.fail;
import static org.junit.Assert.assertTrue;
import android.app.Dialog;
import android.app.NotificationManager;
import android.content.Context;
import android.content.Intent;
import android.os.Looper;
import android.view.View;
import androidx.appcompat.app.AlertDialog;
import org.json.JSONArray;
import org.json.JSONObject;
@@ -13,8 +21,12 @@ import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.RuntimeEnvironment;
import org.robolectric.Shadows;
import org.robolectric.annotation.Config;
import org.robolectric.shadows.ShadowApplication;
import org.robolectric.shadows.ShadowDialog;
import org.robolectric.shadows.ShadowNotificationManager;
import org.robolectric.util.ReflectionHelpers;
import java.util.concurrent.CountDownLatch;
@@ -43,7 +55,7 @@ public class ProjectDetailActivityRealtimeTest {
new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-1"))
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
drainRealtimeDebounce(activity);
assertEquals(0, activity.reloadCount);
assertEquals(1, activity.messageReloadCount);
@@ -68,7 +80,7 @@ public class ProjectDetailActivityRealtimeTest {
new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-2"))
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
drainRealtimeDebounce(activity);
assertEquals(0, activity.reloadCount);
}
@@ -92,7 +104,7 @@ public class ProjectDetailActivityRealtimeTest {
new BossRealtimeEvent("master_agent.task.updated", new JSONObject().put("projectId", "project-2"))
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
drainRealtimeDebounce(activity);
assertEquals(0, activity.reloadCount);
}
@@ -130,10 +142,10 @@ public class ProjectDetailActivityRealtimeTest {
)
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
drainRealtimeDebounce(activity);
assertEquals(1, activity.reloadCount);
assertEquals(1, activity.messageReloadCount);
assertEquals(0, activity.messageReloadCount);
}
@Test
@@ -158,7 +170,7 @@ public class ProjectDetailActivityRealtimeTest {
)
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
drainRealtimeDebounce(activity);
assertEquals(1, activity.reloadCount);
assertEquals(0, activity.messageReloadCount);
@@ -197,12 +209,162 @@ public class ProjectDetailActivityRealtimeTest {
)
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
drainRealtimeDebounce(activity);
assertEquals(0, activity.reloadCount);
assertEquals(1, activity.messageReloadCount);
}
@Test
public void dialogGuardInterventionRequiredShowsBlockedSafeActionDialog() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线");
TestRealtimeProjectDetailActivity activity = Robolectric
.buildActivity(TestRealtimeProjectDetailActivity.class, intent)
.setup()
.resume()
.get();
RecordingDialogGuardApiClient apiClient = new RecordingDialogGuardApiClient();
ReflectionHelpers.setField(activity, "apiClient", apiClient);
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
ReflectionHelpers.ClassParameter.from(
BossRealtimeEvent.class,
new BossRealtimeEvent(
"desktop.dialog_guard.intervention_required",
new JSONObject()
.put("interventionId", "intervention-1")
.put("dialogId", "dialog-1")
.put("requestId", "request-1")
.put("taskId", "task-1")
.put("deviceId", "mac-studio")
.put("projectId", "project-1")
.put("appName", "微信")
.put("platform", "macos")
.put("risk", "blocked")
.put("summary", "微信正在请求读取敏感通讯录权限")
.put("recommendedAction", "handled_on_device")
.put("availableActions", new JSONArray()
.put("allow_once")
.put("allow_for_device_dialog")
.put("deny")
.put("handled_on_device")
.put("cancel_task"))
)
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
Dialog latestDialog = ShadowDialog.getLatestDialog();
assertTrue(latestDialog instanceof AlertDialog);
AlertDialog dialog = (AlertDialog) latestDialog;
assertTrue(dialog.isShowing());
assertTrue(viewTreeContainsText(dialog.getWindow().getDecorView(), "微信"));
assertTrue(viewTreeContainsText(dialog.getWindow().getDecorView(), "微信正在请求读取敏感通讯录权限"));
assertTrue(viewTreeContainsText(dialog.getWindow().getDecorView(), "我已在电脑上处理"));
assertTrue(viewTreeContainsText(dialog.getWindow().getDecorView(), "取消任务"));
assertFalse(viewTreeContainsText(dialog.getWindow().getDecorView(), "允许本次"));
assertFalse(viewTreeContainsText(dialog.getWindow().getDecorView(), "当前设备此弹窗允许"));
View handledButton = findClickableViewContainingText(dialog.getWindow().getDecorView(), "我已在电脑上处理");
assertNotNull(handledButton);
handledButton.performClick();
waitFor(() -> apiClient.decisionCallCount == 1);
assertEquals("intervention-1", apiClient.lastInterventionId);
assertEquals("handled_on_device", apiClient.lastDecision);
}
@Test
public void dialogGuardResolvedEventClosesMatchingDialog() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线");
TestRealtimeProjectDetailActivity activity = Robolectric
.buildActivity(TestRealtimeProjectDetailActivity.class, intent)
.setup()
.resume()
.get();
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
ReflectionHelpers.ClassParameter.from(
BossRealtimeEvent.class,
new BossRealtimeEvent(
"desktop.dialog_guard.intervention_required",
new JSONObject()
.put("interventionId", "intervention-2")
.put("projectId", "project-1")
.put("appName", "访达")
.put("risk", "safe")
.put("summary", "确认打开下载文件")
.put("availableActions", new JSONArray().put("allow_once").put("deny"))
)
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
AlertDialog dialog = (AlertDialog) ShadowDialog.getLatestDialog();
assertTrue(dialog.isShowing());
ReflectionHelpers.callInstanceMethod(
activity,
"handleRealtimeEvent",
ReflectionHelpers.ClassParameter.from(
BossRealtimeEvent.class,
new BossRealtimeEvent(
"desktop.dialog_guard.intervention_resolved",
new JSONObject()
.put("interventionId", "intervention-2")
.put("projectId", "project-1")
)
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
assertFalse(dialog.isShowing());
}
@Test
public void openingMasterAgentConversationClearsPendingMasterAgentNotification() throws Exception {
Context context = RuntimeEnvironment.getApplication();
BossApplication application = (BossApplication) context.getApplicationContext();
ShadowApplication.getInstance().grantPermissions(android.Manifest.permission.POST_NOTIFICATIONS);
ShadowNotificationManager notificationManager = Shadows.shadowOf(
(NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE)
);
application.visibilityTracker().onAppBackgrounded();
JSONObject message = new JSONObject()
.put("id", "master-msg-1")
.put("sender", "master")
.put("senderLabel", "主 Agent · gpt-5.4-mini")
.put("body", "主 Agent 后台回复");
JSONObject payload = new JSONObject()
.put("projectId", "master-agent")
.put("projectMessagesPayload", new JSONObject().put(
"project",
new JSONObject().put("messages", new JSONArray().put(message))
));
assertTrue(application.notificationRouter().maybeNotifyForRealtimeEvent(
new BossRealtimeEvent("project.messages.updated", payload)
));
assertEquals(1, notificationManager.size());
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
Robolectric.buildActivity(TestRealtimeProjectDetailActivity.class, intent)
.setup()
.resume()
.get();
assertEquals(0, notificationManager.size());
}
@Test
public void burstRealtimeEventsWhileReloadingCoalesceIntoSingleFollowUpReload() throws Exception {
Intent intent = new Intent()
@@ -224,7 +386,7 @@ public class ProjectDetailActivityRealtimeTest {
new BossRealtimeEvent("project.messages.updated", new JSONObject().put("projectId", "project-1"))
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
drainRealtimeDebounce(activity);
assertTrue(activity.awaitFirstLoadStarted());
ReflectionHelpers.callInstanceMethod(
@@ -243,7 +405,7 @@ public class ProjectDetailActivityRealtimeTest {
new BossRealtimeEvent("master_agent.task.updated", new JSONObject().put("projectId", "project-1"))
)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
drainRealtimeDebounce(activity);
assertEquals(0, activity.loadCallCount);
assertEquals(1, activity.messageLoadCallCount);
@@ -277,7 +439,7 @@ public class ProjectDetailActivityRealtimeTest {
"handleRealtimeConnectionChanged",
ReflectionHelpers.ClassParameter.from(boolean.class, false)
);
Shadows.shadowOf(activity.getMainLooper()).idle();
drainRealtimeDebounce(activity);
assertEquals(1, activity.reloadCount);
}
@@ -317,6 +479,49 @@ public class ProjectDetailActivityRealtimeTest {
fail("condition not met before timeout");
}
private static void drainRealtimeDebounce(TestRealtimeProjectDetailActivity activity) {
Shadows.shadowOf(activity.getMainLooper()).idleFor(350, TimeUnit.MILLISECONDS);
}
private static boolean viewTreeContainsText(View root, String expectedText) {
if (root instanceof android.widget.TextView) {
CharSequence text = ((android.widget.TextView) root).getText();
if (expectedText.contentEquals(text)) {
return true;
}
}
if (!(root instanceof android.view.ViewGroup)) {
return false;
}
android.view.ViewGroup group = (android.view.ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
if (viewTreeContainsText(group.getChildAt(index), expectedText)) {
return true;
}
}
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 android.view.ViewGroup)) {
return null;
}
android.view.ViewGroup group = (android.view.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 TestRealtimeProjectDetailActivity extends ProjectDetailActivity {
int reloadCount;
int messageReloadCount;
@@ -397,4 +602,22 @@ public class ProjectDetailActivityRealtimeTest {
setRefreshing(false);
}
}
private static final class RecordingDialogGuardApiClient extends BossApiClient {
int decisionCallCount;
String lastInterventionId;
String lastDecision;
RecordingDialogGuardApiClient() {
super(RuntimeEnvironment.getApplication().getSharedPreferences("dialog_guard_test", Context.MODE_PRIVATE), "https://boss.hyzq.net");
}
@Override
public ApiResponse decideDialogGuardIntervention(String interventionId, String decision) throws org.json.JSONException {
decisionCallCount += 1;
lastInterventionId = interventionId;
lastDecision = decision;
return new ApiResponse(200, new JSONObject().put("ok", true));
}
}
}

View File

@@ -3,15 +3,21 @@ 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.assertNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import android.content.Intent;
import android.content.SharedPreferences;
import android.graphics.drawable.ColorDrawable;
import android.content.res.ColorStateList;
import android.graphics.drawable.GradientDrawable;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.EditText;
import android.widget.FrameLayout;
import android.widget.ImageButton;
import android.widget.LinearLayout;
import android.widget.ListView;
@@ -26,6 +32,7 @@ import org.junit.runner.RunWith;
import org.robolectric.Robolectric;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.annotation.Config;
import org.robolectric.android.controller.ActivityController;
import org.robolectric.Shadows;
import org.robolectric.shadows.ShadowDialog;
import org.robolectric.util.ReflectionHelpers;
@@ -34,11 +41,112 @@ import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.TimeZone;
import java.util.function.BooleanSupplier;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class ProjectDetailActivityUiTest {
@Test
public void typingAtInComposerShowsAgentMentionSuggestions() {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
EditText input = activity.findViewById(R.id.project_chat_input);
input.requestFocus();
input.setText("@");
input.setSelection(1);
Shadows.shadowOf(activity.getMainLooper()).idle();
View panel = activity.findViewById(R.id.project_chat_mention_panel);
assertEquals(View.VISIBLE, panel.getVisibility());
assertTrue(viewTreeContainsText(panel, "主Agent"));
assertTrue(viewTreeContainsText(panel, "审计Agent"));
}
@Test
public void tappingMentionSuggestionInsertsAgentMentionAndClosesPanel() {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
EditText input = activity.findViewById(R.id.project_chat_input);
input.requestFocus();
input.setText("@");
input.setSelection(1);
Shadows.shadowOf(activity.getMainLooper()).idle();
View panel = activity.findViewById(R.id.project_chat_mention_panel);
View masterAgentRow = findClickableViewContainingText(panel, "主Agent");
assertNotNull(masterAgentRow);
masterAgentRow.performClick();
assertEquals("@主Agent ", input.getText().toString());
assertEquals(input.getText().length(), input.getSelectionStart());
assertEquals(View.GONE, panel.getVisibility());
}
@Test
public void tappingAuditMentionSuggestionInsertsAuditAgentMention() {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
EditText input = activity.findViewById(R.id.project_chat_input);
input.requestFocus();
input.setText("请看 @审");
input.setSelection(input.getText().length());
Shadows.shadowOf(activity.getMainLooper()).idle();
View panel = activity.findViewById(R.id.project_chat_mention_panel);
View auditAgentRow = findClickableViewContainingText(panel, "审计Agent");
assertNotNull(auditAgentRow);
auditAgentRow.performClick();
assertEquals("请看 @审计Agent ", input.getText().toString());
assertEquals(View.GONE, panel.getVisibility());
}
@Test
public void formatMessageTimeConvertsUtcTimestampIntoLocalTimezoneClock() {
TimeZone original = TimeZone.getDefault();
try {
TimeZone.setDefault(TimeZone.getTimeZone("Asia/Shanghai"));
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
String label = ReflectionHelpers.callInstanceMethod(
activity,
"formatMessageTime",
ReflectionHelpers.ClassParameter.from(String.class, "2026-04-20T09:01:00.000Z")
);
assertEquals("17:01", label);
} finally {
TimeZone.setDefault(original);
}
}
@Test
public void multiSelectModeUpdatesRealChatChrome() {
Intent intent = new Intent()
@@ -73,12 +181,14 @@ public class ProjectDetailActivityUiTest {
LinearLayout multiSelectActions = activity.findViewById(R.id.project_chat_multi_select_actions);
ImageButton backButton = activity.findViewById(R.id.screen_back_button);
ImageButton refreshButton = activity.findViewById(R.id.screen_refresh_button);
Button copyButton = activity.findViewById(R.id.project_chat_multi_copy);
Button forwardButton = activity.findViewById(R.id.project_chat_multi_forward);
assertEquals(View.GONE, composerRow.getVisibility());
assertEquals(View.VISIBLE, multiSelectActions.getVisibility());
assertEquals("取消", String.valueOf(backButton.getContentDescription()));
assertEquals(View.GONE, refreshButton.getVisibility());
assertTrue(copyButton.isEnabled());
assertEquals(false, forwardButton.isEnabled());
secondMessage.performClick();
@@ -92,6 +202,101 @@ public class ProjectDetailActivityUiTest {
assertEquals(View.GONE, refreshButton.getVisibility());
}
@Test
public void systemBackInMultiSelectModeExitsSelectionInsteadOfClosingConversation() {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
ReflectionHelpers.setField(activity, "conversationInfoReady", true);
ReflectionHelpers.setField(activity, "currentScreenTitle", "北区试产线回归");
ReflectionHelpers.setField(activity, "currentScreenSubtitle", "归档确认");
ReflectionHelpers.callInstanceMethod(
activity,
"enterMultiSelectFromMessage",
ReflectionHelpers.ClassParameter.from(String.class, "m1")
);
assertEquals(View.GONE, activity.findViewById(R.id.project_chat_composer_row).getVisibility());
assertEquals(View.VISIBLE, activity.findViewById(R.id.project_chat_multi_select_actions).getVisibility());
activity.getOnBackPressedDispatcher().onBackPressed();
assertEquals(0, activity.finishCallCount);
assertEquals(View.VISIBLE, activity.findViewById(R.id.project_chat_composer_row).getVisibility());
assertEquals(View.GONE, activity.findViewById(R.id.project_chat_multi_select_actions).getVisibility());
assertEquals("返回", String.valueOf(((ImageButton) activity.findViewById(R.id.screen_back_button)).getContentDescription()));
}
@Test
public void multiSelectModeShowsCheckmarksBeforeMessagesAndCopiesTranscript() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
JSONObject payload = new JSONObject()
.put("project", new JSONObject()
.put("id", "project-1")
.put("name", "北区试产线回归")
.put("messages", new JSONArray()
.put(new JSONObject()
.put("id", "msg-user")
.put("sender", "user")
.put("senderLabel", "Boss 超级管理员")
.put("body", "请同步项目目标")
.put("kind", "text")
.put("sentAt", "2026-04-20T09:01:00+08:00"))
.put(new JSONObject()
.put("id", "msg-master")
.put("sender", "master")
.put("senderLabel", "主 Agent · gpt-5.4-mini")
.put("body", "我会先核对目标,再更新版本记录。")
.put("kind", "text")
.put("sentAt", "2026-04-20T09:02:00+08:00"))));
ReflectionHelpers.callInstanceMethod(
activity,
"renderProject",
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload),
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
);
ReflectionHelpers.callInstanceMethod(
activity,
"enterMultiSelectFromMessage",
ReflectionHelpers.ClassParameter.from(String.class, "msg-user")
);
ReflectionHelpers.callInstanceMethod(
activity,
"toggleMultiSelectMessage",
ReflectionHelpers.ClassParameter.from(String.class, "msg-master")
);
View content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, ""));
assertTrue(viewTreeContainsText(content, "你 · 09:01"));
assertTrue(viewTreeContainsText(content, "主Agent · 09:02"));
Button copyButton = activity.findViewById(R.id.project_chat_multi_copy);
copyButton.performClick();
android.content.ClipData clipData = activity
.getSystemService(android.content.ClipboardManager.class)
.getPrimaryClip();
assertNotNull(clipData);
String copied = String.valueOf(clipData.getItemAt(0).coerceToText(activity));
assertTrue(copied.contains("09:01 你:请同步项目目标"));
assertTrue(copied.contains("09:02 主Agent我会先核对目标再更新版本记录。"));
}
@Test
public void composerFocus_scrollsChatToBottomToKeepLatestMessageVisible() {
Intent intent = new Intent()
@@ -148,6 +353,56 @@ public class ProjectDetailActivityUiTest {
assertNotNull(childScrollCallback);
}
@Test
public void renderProjectWithUnread_marksConversationReadOncePerVisibleSession() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
ActivityController<TestProjectDetailActivity> controller = Robolectric.buildActivity(TestProjectDetailActivity.class, intent);
TestProjectDetailActivity activity = controller.setup().get();
RecordingConversationActionApiClient apiClient = new RecordingConversationActionApiClient();
ReflectionHelpers.setField(activity, "apiClient", apiClient);
JSONObject payload = new JSONObject()
.put("project", new JSONObject()
.put("id", "project-1")
.put("name", "北区试产线回归")
.put("unreadCount", 3)
.put("messages", new JSONArray()));
ReflectionHelpers.callInstanceMethod(
activity,
"renderProject",
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload),
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
);
waitForUiCondition(activity, () -> apiClient.markConversationReadCount == 1);
assertEquals("project-1", apiClient.lastMarkedProjectId);
ReflectionHelpers.callInstanceMethod(
activity,
"renderProject",
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload),
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
);
Thread.sleep(80L);
assertEquals(1, apiClient.markConversationReadCount);
controller.pause();
controller.resume();
ReflectionHelpers.callInstanceMethod(
activity,
"renderProject",
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload),
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
);
waitForUiCondition(activity, () -> apiClient.markConversationReadCount == 2);
}
@Test
public void composerRowLayoutChangeWithFocusedInput_scrollsChatToBottomAgain() {
Intent intent = new Intent()
@@ -235,6 +490,110 @@ public class ProjectDetailActivityUiTest {
assertTrue(params.height >= BossUi.dp(activity, 46));
}
@Test
public void scrollBottomShortcutIsFloatingIconAboveComposerAndTriggersBottomScroll() {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "北区试产线回归");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
int shortcutId = activity.getResources().getIdentifier(
"project_chat_scroll_bottom",
"id",
activity.getPackageName()
);
assertTrue("project_chat_scroll_bottom id should exist", shortcutId != 0);
View shortcutView = activity.findViewById(shortcutId);
assertNotNull(shortcutView);
assertTrue(shortcutView instanceof ImageButton);
assertEquals(View.GONE, shortcutView.getVisibility());
assertTrue(shortcutView.getLayoutParams() instanceof FrameLayout.LayoutParams);
FrameLayout.LayoutParams params = (FrameLayout.LayoutParams) shortcutView.getLayoutParams();
assertTrue((params.gravity & Gravity.BOTTOM) == Gravity.BOTTOM);
assertTrue((params.gravity & Gravity.LEFT) == Gravity.LEFT || (params.gravity & Gravity.START) == Gravity.START);
assertEquals(BossUi.dp(activity, 12), params.leftMargin);
assertTrue(params.bottomMargin >= BossUi.dp(activity, 12));
assertEquals(BossUi.dp(activity, 48), params.width);
assertEquals(BossUi.dp(activity, 48), params.height);
int baselineScrollCount = activity.scrollChatToBottomCount;
shortcutView.performClick();
assertTrue(activity.scrollChatToBottomCount > baselineScrollCount);
}
@Test
public void scrollBottomShortcutVisibilityLogicMatchesObservedSwipeDirection() {
Boolean farFromBottom = ReflectionHelpers.callStaticMethod(
ProjectDetailActivity.class,
"shouldShowScrollBottomShortcut",
ReflectionHelpers.ClassParameter.from(int.class, 140),
ReflectionHelpers.ClassParameter.from(int.class, 96),
ReflectionHelpers.ClassParameter.from(int.class, 460),
ReflectionHelpers.ClassParameter.from(int.class, 400),
ReflectionHelpers.ClassParameter.from(boolean.class, false)
);
Boolean oppositeDirection = ReflectionHelpers.callStaticMethod(
ProjectDetailActivity.class,
"shouldShowScrollBottomShortcut",
ReflectionHelpers.ClassParameter.from(int.class, 140),
ReflectionHelpers.ClassParameter.from(int.class, 96),
ReflectionHelpers.ClassParameter.from(int.class, 320),
ReflectionHelpers.ClassParameter.from(int.class, 400),
ReflectionHelpers.ClassParameter.from(boolean.class, true)
);
Boolean keepVisibleWhileStopped = ReflectionHelpers.callStaticMethod(
ProjectDetailActivity.class,
"shouldShowScrollBottomShortcut",
ReflectionHelpers.ClassParameter.from(int.class, 140),
ReflectionHelpers.ClassParameter.from(int.class, 96),
ReflectionHelpers.ClassParameter.from(int.class, 400),
ReflectionHelpers.ClassParameter.from(int.class, 400),
ReflectionHelpers.ClassParameter.from(boolean.class, true)
);
Boolean alreadyNearBottom = ReflectionHelpers.callStaticMethod(
ProjectDetailActivity.class,
"shouldShowScrollBottomShortcut",
ReflectionHelpers.ClassParameter.from(int.class, 80),
ReflectionHelpers.ClassParameter.from(int.class, 96),
ReflectionHelpers.ClassParameter.from(int.class, 320),
ReflectionHelpers.ClassParameter.from(int.class, 400),
ReflectionHelpers.ClassParameter.from(boolean.class, true)
);
assertTrue(farFromBottom);
assertFalse(oppositeDirection);
assertTrue(keepVisibleWhileStopped);
assertFalse(alreadyNearBottom);
}
@Test
public void normalConversationHeaderActionOpensConversationInfoDirectlyWithoutDialog() {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "project-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "Boss 移动控制台");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
ReflectionHelpers.setField(activity, "conversationInfoReady", true);
ReflectionHelpers.setField(activity, "currentScreenTitle", "Boss 移动控制台");
ReflectionHelpers.setField(activity, "currentScreenSubtitle", "归档确认");
ReflectionHelpers.callInstanceMethod(activity, "updateSelectionUi");
ImageButton headerAction = activity.findViewById(R.id.screen_header_action);
ShadowDialog.reset();
headerAction.performClick();
assertNull(ShadowDialog.getLatestDialog());
}
@Test
public void manualAnalysisAttachmentShowsActionChip() throws Exception {
Intent intent = new Intent()
@@ -358,7 +717,7 @@ public class ProjectDetailActivityUiTest {
InMemorySharedPreferences prefs = new InMemorySharedPreferences();
prefs.edit()
.putString("account", "17600003315")
.putString("account", "krisolo")
.putString("display_name", "OpenAI 平台账号")
.apply();
ReflectionHelpers.setField(activity, "apiClient", new BossApiClient(prefs, "https://boss.hyzq.net"));
@@ -369,7 +728,7 @@ public class ProjectDetailActivityUiTest {
.put("senderLabel", "Boss 超级管理员")
.put("body", "请只回复一句:聊天链路自检正常。")
.put("kind", "text")
.put("sentAt", "2026-03-31T10:26:00.000Z");
.put("sentAt", "2026-03-31T10:26:00+08:00");
View messageView = ReflectionHelpers.callInstanceMethod(
activity,
@@ -377,10 +736,174 @@ public class ProjectDetailActivityUiTest {
ReflectionHelpers.ClassParameter.from(JSONObject.class, message)
);
assertTrue(viewTreeContainsText(messageView, "10:26"));
assertTrue(viewTreeContainsText(messageView, "你 · 10:26"));
assertFalse(viewTreeContainsText(messageView, "Boss 超级管理员 · 10:26"));
}
@Test
public void masterAgentMessageUsesStableSpeakerLabelAndLightBlueBubble() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
JSONObject message = new JSONObject()
.put("id", "msg-master-1")
.put("sender", "master")
.put("senderLabel", "主 Agent · gpt-5.4-mini")
.put("body", "我会先核对目标,再同步到顶部入口。")
.put("kind", "text")
.put("sentAt", "2026-04-20T09:16:00+08:00");
View messageView = ReflectionHelpers.callInstanceMethod(
activity,
"buildMessageView",
ReflectionHelpers.ClassParameter.from(JSONObject.class, message)
);
assertTrue(viewTreeContainsText(messageView, "主Agent · 09:16"));
assertTrue(viewTreeHasGradientColor(messageView, 0xFFEAF5FF));
}
@Test
public void renderThreadMessageUsesBoundCodexDeviceAvatar() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "thread-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "Boss开发主线程");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
JSONObject payload = new JSONObject()
.put("project", new JSONObject()
.put("id", "thread-1")
.put("name", "Boss开发主线程")
.put("deviceIds", new JSONArray().put("mac-studio"))
.put("messages", new JSONArray()
.put(new JSONObject()
.put("id", "msg-device-1")
.put("sender", "device")
.put("senderLabel", "Boss开发主线程 · Mac Studio")
.put("body", "已完成构建检查。")
.put("kind", "text")
.put("sentAt", "2026-05-09T09:10:00+08:00"))))
.put("devices", new JSONArray()
.put(new JSONObject()
.put("id", "mac-studio")
.put("name", "Mac Studio")
.put("avatar", "M")));
ReflectionHelpers.callInstanceMethod(
activity,
"renderProject",
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload),
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
);
View content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "M"));
assertTrue(viewTreeContainsContentDescription(content, "来自 Mac Studio"));
}
@Test
public void renderGroupThreadMessageMatchesAvatarByCodexDeviceName() 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();
JSONObject payload = new JSONObject()
.put("project", new JSONObject()
.put("id", "group-1")
.put("name", "协作群")
.put("isGroup", true)
.put("deviceIds", new JSONArray().put("mac-studio").put("windows-gpu"))
.put("messages", new JSONArray()
.put(new JSONObject()
.put("id", "msg-device-2")
.put("sender", "device")
.put("senderLabel", "购物车修复 · Windows GPU")
.put("body", "Windows 线程已回写结果。")
.put("kind", "text")
.put("sentAt", "2026-05-09T09:16:00+08:00"))))
.put("devices", new JSONArray()
.put(new JSONObject()
.put("id", "mac-studio")
.put("name", "Mac Studio")
.put("avatar", "M"))
.put(new JSONObject()
.put("id", "windows-gpu")
.put("name", "Windows GPU")
.put("avatar", "W")));
ReflectionHelpers.callInstanceMethod(
activity,
"renderProject",
ReflectionHelpers.ClassParameter.from(JSONObject.class, payload),
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
);
View content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content, "W"));
assertTrue(viewTreeContainsContentDescription(content, "来自 Windows GPU"));
}
@Test
public void executionProgressMessageRendersAsStructuredCard() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "thread-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "Boss开发主线程");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
JSONObject message = new JSONObject()
.put("id", "progress-1")
.put("sender", "master")
.put("senderLabel", "主 Agent")
.put("body", "执行进度")
.put("kind", "execution_progress")
.put("sentAt", "2026-05-08T10:16:00+08:00")
.put("executionProgress", new JSONObject()
.put("status", "completed")
.put("steps", new JSONArray()
.put(new JSONObject().put("text", "回读计划和 H5 商品支付链现状").put("status", "done"))
.put(new JSONObject().put("text", "运行 targeted/full test、typecheck 和 diff 检查").put("status", "done")))
.put("branch", new JSONObject()
.put("additions", 181500)
.put("deletions", 52)
.put("githubCliStatus", "unavailable"))
.put("artifacts", new JSONArray()
.put(new JSONObject().put("label", "development_version_log_20260508.md").put("kind", "file"))
.put(new JSONObject().put("label", "已生成图像 1").put("kind", "image")))
.put("agents", new JSONArray()
.put(new JSONObject().put("name", "Mendel").put("role", "explorer"))));
View messageView = ReflectionHelpers.callInstanceMethod(
activity,
"buildMessageView",
ReflectionHelpers.ClassParameter.from(JSONObject.class, message)
);
assertTrue(viewTreeContainsText(messageView, "进度"));
assertTrue(viewTreeContainsText(messageView, "回读计划和 H5 商品支付链现状"));
assertTrue(viewTreeContainsText(messageView, "+181,500"));
assertTrue(viewTreeContainsText(messageView, "-52"));
assertTrue(viewTreeContainsText(messageView, "GitHub CLI 不可用"));
assertTrue(viewTreeContainsText(messageView, "development_version_log_20260508.md"));
assertTrue(viewTreeContainsText(messageView, "Mendelexplorer"));
}
@Test
public void completedReplyResponseRendersImmediatelyWithoutReloadingProjectDetail() throws Exception {
Intent intent = new Intent()
@@ -473,6 +996,170 @@ public class ProjectDetailActivityUiTest {
assertEquals("更多", String.valueOf(headerAction.getContentDescription()));
}
@Test
public void completedBrowserControlResponseShowsControlSummaryInConversation() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
ReflectionHelpers.setField(activity, "conversationInfoReady", true);
ReflectionHelpers.setField(activity, "currentScreenTitle", "主 Agent");
ReflectionHelpers.setField(activity, "currentScreenSubtitle", "单聊会话");
JSONObject initialPayload = new JSONObject()
.put("project", new JSONObject()
.put("id", "master-agent")
.put("name", "主 Agent")
.put("messages", new JSONArray()));
ReflectionHelpers.callInstanceMethod(
activity,
"renderProject",
ReflectionHelpers.ClassParameter.from(JSONObject.class, initialPayload),
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
);
JSONObject userMessage = new JSONObject()
.put("id", "msg-user-browser")
.put("sender", "user")
.put("senderLabel", "Boss 超级管理员")
.put("body", "打开 https://example.com 看一下首页")
.put("kind", "text")
.put("sentAt", "2026-04-17T10:00:00.000Z");
JSONObject replyMessage = new JSONObject()
.put("id", "msg-master-browser")
.put("sender", "master")
.put("senderLabel", "主 Agent · gpt-5.4-mini")
.put("body", "浏览器控制已完成:打开 https://example.com 看一下首页")
.put("kind", "text")
.put("sentAt", "2026-04-17T10:00:01.000Z");
JSONObject sendResponse = new JSONObject()
.put("ok", true)
.put("message", userMessage)
.put("replyMessage", replyMessage)
.put("masterReplyState", "completed")
.put("replyPresenter", "master")
.put("executionMode", "browser")
.put("riskLevel", "medium")
.put("requiresConfirmation", true)
.put("targetUrl", "https://example.com")
.put("task", JSONObject.NULL)
.put("dispatchPlan", JSONObject.NULL)
.put("collaborationGate", new JSONObject()
.put("isGroup", false)
.put("collaborationMode", "development")
.put("approvalState", "not_required"));
CompletedReplyApiClient fakeApiClient = new CompletedReplyApiClient(sendResponse);
ReflectionHelpers.setField(activity, "apiClient", fakeApiClient);
ReflectionHelpers.callInstanceMethod(
activity,
"sendProjectMessage",
ReflectionHelpers.ClassParameter.from(String.class, "text"),
ReflectionHelpers.ClassParameter.from(String.class, "打开 https://example.com 看一下首页")
);
Shadows.shadowOf(activity.getMainLooper()).idle();
JSONObject controlSummary = ReflectionHelpers.callInstanceMethod(
activity,
"buildControlSummaryMessageIfNeeded",
ReflectionHelpers.ClassParameter.from(JSONObject.class, sendResponse)
);
assertNotNull(controlSummary);
assertEquals("control_summary", controlSummary.optString("kind"));
assertEquals("https://example.com", controlSummary.optString("controlTarget"));
assertEquals("浏览器控制已完成:打开 https://example.com 看一下首页", controlSummary.optString("body"));
assertEquals(0, fakeApiClient.projectDetailCallCount);
}
@Test
public void completedDesktopControlResponseShowsControlSummaryInConversation() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "master-agent")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "主 Agent");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
ReflectionHelpers.setField(activity, "conversationInfoReady", true);
ReflectionHelpers.setField(activity, "currentScreenTitle", "主 Agent");
ReflectionHelpers.setField(activity, "currentScreenSubtitle", "单聊会话");
JSONObject initialPayload = new JSONObject()
.put("project", new JSONObject()
.put("id", "master-agent")
.put("name", "主 Agent")
.put("messages", new JSONArray()));
ReflectionHelpers.callInstanceMethod(
activity,
"renderProject",
ReflectionHelpers.ClassParameter.from(JSONObject.class, initialPayload),
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
);
JSONObject userMessage = new JSONObject()
.put("id", "msg-user-desktop")
.put("sender", "user")
.put("senderLabel", "Boss 超级管理员")
.put("body", "打开微信并准备切到聊天窗口")
.put("kind", "text")
.put("sentAt", "2026-04-17T10:05:00.000Z");
JSONObject replyMessage = new JSONObject()
.put("id", "msg-master-desktop")
.put("sender", "master")
.put("senderLabel", "主 Agent · gpt-5.4-mini")
.put("body", "桌面控制已完成:打开微信并准备切到聊天窗口")
.put("kind", "text")
.put("sentAt", "2026-04-17T10:05:01.000Z");
JSONObject sendResponse = new JSONObject()
.put("ok", true)
.put("message", userMessage)
.put("replyMessage", replyMessage)
.put("masterReplyState", "completed")
.put("replyPresenter", "master")
.put("executionMode", "desktop")
.put("riskLevel", "medium")
.put("requiresConfirmation", true)
.put("targetApp", "微信")
.put("task", JSONObject.NULL)
.put("dispatchPlan", JSONObject.NULL)
.put("collaborationGate", new JSONObject()
.put("isGroup", false)
.put("collaborationMode", "development")
.put("approvalState", "not_required"));
CompletedReplyApiClient fakeApiClient = new CompletedReplyApiClient(sendResponse);
ReflectionHelpers.setField(activity, "apiClient", fakeApiClient);
ReflectionHelpers.callInstanceMethod(
activity,
"sendProjectMessage",
ReflectionHelpers.ClassParameter.from(String.class, "text"),
ReflectionHelpers.ClassParameter.from(String.class, "打开微信并准备切到聊天窗口")
);
Shadows.shadowOf(activity.getMainLooper()).idle();
JSONObject controlSummary = ReflectionHelpers.callInstanceMethod(
activity,
"buildControlSummaryMessageIfNeeded",
ReflectionHelpers.ClassParameter.from(JSONObject.class, sendResponse)
);
assertNotNull(controlSummary);
assertEquals("control_summary", controlSummary.optString("kind"));
assertEquals("微信", controlSummary.optString("controlTarget"));
assertEquals("桌面控制已完成:打开微信并准备切到聊天窗口", controlSummary.optString("body"));
assertEquals(0, fakeApiClient.projectDetailCallCount);
}
@Test
public void normalConversationHeaderUsesWechatMoreMenuLabel() {
Intent intent = new Intent()
@@ -635,6 +1322,80 @@ public class ProjectDetailActivityUiTest {
assertEquals(null, ReflectionHelpers.getField(activity, "masterAgentReplyBaselineMessageId"));
}
@Test
public void startReplyWaitTracksMasterRelayInThreadConversation() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "thread-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "AI 眼镜线程");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
ReflectionHelpers.setField(activity, "pendingReplyPresenter", "master");
JSONObject sendResponse = new JSONObject()
.put("message", new JSONObject().put("id", "msg-user-1"))
.put("task", new JSONObject()
.put("taskId", "task-1")
.put("taskType", "conversation_reply")
.put("status", "queued"));
ProjectChatUiState.ReplyWaitSpec waitSpec =
ProjectChatUiState.resolveReplyWaitAfterSend(sendResponse);
ReflectionHelpers.callInstanceMethod(
activity,
"startReplyWait",
ReflectionHelpers.ClassParameter.from(ProjectChatUiState.ReplyWaitSpec.class, waitSpec),
ReflectionHelpers.ClassParameter.from(boolean.class, false),
ReflectionHelpers.ClassParameter.from(String.class, "消息已发送,主 Agent 正在转述")
);
assertTrue(ReflectionHelpers.<Boolean>getField(activity, "masterAgentReplyWaiting"));
assertFalse(ReflectionHelpers.<Boolean>getField(activity, "masterAgentReplyTimedOut"));
assertEquals("msg-user-1", ReflectionHelpers.getField(activity, "masterAgentReplyBaselineMessageId"));
assertEquals(1, activity.replyWaitPollCount);
}
@Test
public void renderThreadProjectClearsMasterRelayWaitStateAfterNewReplyArrives() throws Exception {
Intent intent = new Intent()
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_ID, "thread-1")
.putExtra(ProjectDetailActivity.EXTRA_PROJECT_NAME, "AI 眼镜线程");
TestProjectDetailActivity activity = Robolectric
.buildActivity(TestProjectDetailActivity.class, intent)
.setup()
.get();
ReflectionHelpers.setField(activity, "conversationInfoReady", true);
ReflectionHelpers.setField(activity, "currentScreenTitle", "AI 眼镜线程");
ReflectionHelpers.setField(activity, "currentScreenSubtitle", "单聊会话");
ReflectionHelpers.setField(activity, "pendingReplyPresenter", "master");
ReflectionHelpers.setField(activity, "masterAgentReplyWaiting", false);
ReflectionHelpers.setField(activity, "masterAgentReplyTimedOut", true);
ReflectionHelpers.setField(activity, "masterAgentReplyBaselineMessageId", "msg-user-1");
JSONObject project = new JSONObject()
.put("project", new JSONObject()
.put("id", "thread-1")
.put("name", "AI 眼镜线程")
.put("messages", new JSONArray()
.put(new JSONObject().put("id", "msg-user-1").put("sender", "user"))
.put(new JSONObject().put("id", "msg-master-1").put("sender", "master"))));
ReflectionHelpers.callInstanceMethod(
activity,
"renderProject",
ReflectionHelpers.ClassParameter.from(JSONObject.class, project),
ReflectionHelpers.ClassParameter.from(JSONArray.class, null),
ReflectionHelpers.ClassParameter.from(JSONObject.class, null)
);
assertFalse(ReflectionHelpers.<Boolean>getField(activity, "masterAgentReplyWaiting"));
assertFalse(ReflectionHelpers.<Boolean>getField(activity, "masterAgentReplyTimedOut"));
assertEquals(null, ReflectionHelpers.getField(activity, "masterAgentReplyBaselineMessageId"));
assertEquals(null, ReflectionHelpers.getField(activity, "pendingReplyPresenter"));
}
@Test
public void outgoingAttachmentMetaPrefersTimeOnly() throws Exception {
Intent intent = new Intent()
@@ -667,8 +1428,7 @@ public class ProjectDetailActivityUiTest {
ReflectionHelpers.ClassParameter.from(boolean.class, true)
);
assertTrue(viewTreeContainsText(attachmentView, "09:26"));
assertFalse(viewTreeContainsText(attachmentView, "你 · 09:26"));
assertTrue(viewTreeContainsText(attachmentView, "你 · 09:26"));
}
@Test
@@ -917,6 +1677,67 @@ public class ProjectDetailActivityUiTest {
return false;
}
private static boolean viewTreeContainsContentDescription(View root, String expectedText) {
if (root == null) {
return false;
}
CharSequence description = root.getContentDescription();
if (description != null && expectedText.contentEquals(description)) {
return true;
}
if (!(root instanceof ViewGroup)) {
return false;
}
ViewGroup group = (ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
if (viewTreeContainsContentDescription(group.getChildAt(index), expectedText)) {
return true;
}
}
return false;
}
private static boolean viewTreeHasBackgroundColor(View root, int expectedColor) {
if (root.getBackground() instanceof ColorDrawable) {
return ((ColorDrawable) root.getBackground()).getColor() == expectedColor;
}
if (root.getBackground() instanceof GradientDrawable) {
ColorStateList color = ((GradientDrawable) root.getBackground()).getColor();
if (color != null && color.getDefaultColor() == expectedColor) {
return true;
}
}
if (!(root instanceof ViewGroup)) {
return false;
}
ViewGroup group = (ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
if (viewTreeHasBackgroundColor(group.getChildAt(index), expectedColor)) {
return true;
}
}
return false;
}
private static boolean viewTreeHasGradientColor(View root, int expectedColor) {
if (root.getBackground() instanceof GradientDrawable) {
ColorStateList color = ((GradientDrawable) root.getBackground()).getColor();
if (color != null && color.getDefaultColor() == expectedColor) {
return true;
}
}
if (!(root instanceof ViewGroup)) {
return false;
}
ViewGroup group = (ViewGroup) root;
for (int index = 0; index < group.getChildCount(); index += 1) {
if (viewTreeHasGradientColor(group.getChildAt(index), expectedColor)) {
return true;
}
}
return false;
}
private static View findClickableViewContainingText(View root, String expectedText) {
if (root == null) {
return null;
@@ -954,6 +1775,7 @@ public class ProjectDetailActivityUiTest {
String lastReplyWaitBaselineMessageId;
boolean lastReplyWaitIncludeDispatchPlans;
int scrollChatToBottomCount;
int finishCallCount;
@Override
boolean shouldLoadOnCreate() {
@@ -971,6 +1793,12 @@ public class ProjectDetailActivityUiTest {
void scrollChatToBottom() {
scrollChatToBottomCount += 1;
}
@Override
public void finish() {
finishCallCount += 1;
super.finish();
}
}
private static final class CompletedReplyApiClient extends BossApiClient {
@@ -1002,6 +1830,22 @@ public class ProjectDetailActivityUiTest {
}
}
private static final class RecordingConversationActionApiClient extends BossApiClient {
int markConversationReadCount;
String lastMarkedProjectId;
RecordingConversationActionApiClient() {
super(new InMemorySharedPreferences(), "https://boss.hyzq.net");
}
@Override
public ApiResponse markConversationRead(String projectId) throws org.json.JSONException {
markConversationReadCount += 1;
lastMarkedProjectId = projectId;
return new ApiResponse(200, new JSONObject().put("ok", true));
}
}
private static final class InMemorySharedPreferences implements SharedPreferences {
private final Map<String, Object> values = new HashMap<>();

View File

@@ -0,0 +1,96 @@
package com.hyzq.boss;
import static org.junit.Assert.assertTrue;
import android.content.Intent;
import android.view.View;
import android.view.ViewGroup;
import org.json.JSONArray;
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.util.ReflectionHelpers;
@RunWith(RobolectricTestRunner.class)
@Config(sdk = 34)
public class TelegramIntegrationActivityTest {
@Test
public void populateShowsCurrentTelegramStatusBeforeEditableForm() throws Exception {
TestTelegramIntegrationActivity activity = Robolectric
.buildActivity(TestTelegramIntegrationActivity.class, new Intent())
.setup()
.get();
JSONObject telegram = new JSONObject()
.put("enabled", true)
.put("mode", "webhook")
.put("botTokenConfigured", true)
.put("webhookSecretConfigured", true)
.put("botUsername", "boss_demo_bot")
.put("defaultProjectId", "master-agent")
.put("processedUpdateCount", 3)
.put("lastError", "上次 webhook 同步失败")
.put("allowFrom", new JSONArray().put("123456"))
.put("groups", new JSONArray().put("-10001"))
.put(
"groupProjectRoutes",
new JSONArray().put(
new JSONObject()
.put("chatId", "-10001")
.put("threadId", 12)
.put("projectId", "audit-collab")
.put("label", "审计 Topic")
)
)
.put("dmPolicy", "allowlist")
.put("groupPolicy", "allowlist")
.put("requireMentionInGroups", true);
ReflectionHelpers.callInstanceMethod(
activity,
"populate",
ReflectionHelpers.ClassParameter.from(JSONObject.class, telegram)
);
ViewGroup content = activity.findViewById(R.id.screen_content);
assertTrue(viewTreeContainsText(content.getChildAt(0), "当前状态"));
assertTrue(viewTreeContainsText(content.getChildAt(0), "接入:已开启"));
assertTrue(viewTreeContainsText(content.getChildAt(0), "模式Webhook"));
assertTrue(viewTreeContainsText(content.getChildAt(0), "Bot@boss_demo_bot"));
assertTrue(viewTreeContainsText(content.getChildAt(0), "Token已配置"));
assertTrue(viewTreeContainsText(content.getChildAt(0), "Webhook Secret已配置"));
assertTrue(viewTreeContainsText(content.getChildAt(0), "已处理 update3"));
assertTrue(viewTreeContainsText(content.getChildAt(0), "最近错误:上次 webhook 同步失败"));
assertTrue(viewTreeContainsText(content, "群 / Topic 路由"));
assertTrue(viewTreeContainsText(content, "-10001#12 audit-collab 审计 Topic"));
}
private static boolean viewTreeContainsText(View view, String text) {
if (view instanceof android.widget.TextView) {
CharSequence value = ((android.widget.TextView) view).getText();
if (value != null && value.toString().contains(text)) {
return true;
}
}
if (view instanceof ViewGroup) {
ViewGroup group = (ViewGroup) view;
for (int index = 0; index < group.getChildCount(); index += 1) {
if (viewTreeContainsText(group.getChildAt(index), text)) {
return true;
}
}
}
return false;
}
public static class TestTelegramIntegrationActivity extends TelegramIntegrationActivity {
@Override
protected void reload() {
// Tests drive rendering directly through populate().
}
}
}

View File

@@ -137,13 +137,96 @@ public class WechatSurfaceMapperTest {
assertEquals("已导入线程", row.lastMessagePreview);
}
@Test
public void toConversationRow_sanitizesLeakedPromptTitleToFolderFallback() throws Exception {
JSONObject item = new JSONObject()
.put("conversationType", "single_device")
.put("projectTitle", "你当前接手的项目根目录是:")
.put("threadTitle", "你当前接手的项目根目录是:")
.put("folderLabel", "boss")
.put("latestReplyLabel", "17:35");
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
assertEquals("boss", row.threadTitle);
}
@Test
public void toConversationRow_extractsWorkspaceFolderFromPromptLeakTitle() throws Exception {
JSONObject item = new JSONObject()
.put("conversationType", "single_device")
.put("projectTitle", "你现在接手的项目根目录是 /Users/kris/code/yuandi。")
.put("threadTitle", "你现在接手的项目根目录是 /Users/kris/code/yuandi。")
.put("latestReplyLabel", "17:36");
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
assertEquals("yuandi", row.threadTitle);
}
@Test
public void toConversationRow_prefersStableMasterAgentProjectTitleOverOperationalThreadTitle() throws Exception {
JSONObject item = new JSONObject()
.put("projectId", "master-agent")
.put("projectTitle", "主 Agent")
.put("threadTitle", "主 Agent 汇总")
.put("lastMessagePreview", "同步已完成")
.put("latestReplyLabel", "10:18");
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
assertEquals("主 Agent", row.threadTitle);
assertEquals("同步已完成", row.lastMessagePreview);
}
@Test
public void toConversationRow_prefersStableAuditProjectTitleOverOperationalThreadTitle() throws Exception {
JSONObject item = new JSONObject()
.put("projectId", "audit-collab")
.put("projectTitle", "硬件审计协作")
.put("threadTitle", "审计对话")
.put("lastMessagePreview", "审计结果已回写")
.put("latestReplyLabel", "10:20");
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
assertEquals("硬件审计协作", row.threadTitle);
assertEquals("审计结果已回写", row.lastMessagePreview);
}
@Test
public void toConversationRow_hidesProcessLikePreviewFallback() throws Exception {
JSONObject item = new JSONObject()
.put("projectTitle", "Boss")
.put("threadTitle", "Boss开发主线程")
.put("lastMessagePreview", "我继续往下收,这一轮先检查折叠链路,再确认未读逻辑,随后回你结果。")
.put("latestReplyLabel", "10:20");
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
assertEquals("", row.lastMessagePreview);
}
@Test
public void toConversationRow_keepsFinalSummaryPreviewVisible() throws Exception {
JSONObject item = new JSONObject()
.put("projectTitle", "Boss")
.put("threadTitle", "Boss开发主线程")
.put("lastMessagePreview", "折叠修复已部署,未读数现在只按最终结果计数。")
.put("latestReplyLabel", "10:22");
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
assertEquals("折叠修复已部署,未读数现在只按最终结果计数。", row.lastMessagePreview);
}
@Test
public void toDeviceRow_mapsLegacyWechatThreeLineSummary() throws Exception {
JSONObject item = new StubJSONObject()
.withString("name", "Mac Studio")
.withString("avatar", "M")
.withString("status", "online")
.withString("account", "17600003315")
.withString("account", "krisolo")
.withStringArray("projects", "北区试产线回归", "容灾切换验证")
.withInt("quota5h", 8)
.withInt("quota7d", 22);
@@ -151,7 +234,7 @@ public class WechatSurfaceMapperTest {
WechatSurfaceMapper.DeviceRow row = WechatSurfaceMapper.toDeviceRow(item);
assertEquals("Mac Studio", row.title);
assertEquals("账号: 17600003315 · 项目: 北区试产线回归 / 容灾切换验证", row.subtitle);
assertEquals("账号: krisolo · 项目: 北区试产线回归 / 容灾切换验证", row.subtitle);
assertEquals("额度: 5h 8% · 7d 22%", row.meta);
assertEquals("M", row.avatarLabel);
assertEquals("online", row.statusKey);
@@ -162,12 +245,12 @@ public class WechatSurfaceMapperTest {
JSONObject item = new StubJSONObject()
.withString("name", "Mac Studio")
.withString("status", "abnormal")
.withString("account", "17600003315");
.withString("account", "krisolo");
WechatSurfaceMapper.DeviceRow row = WechatSurfaceMapper.toDeviceRow(item);
assertEquals("Mac Studio", row.title);
assertEquals("账号: 17600003315", row.subtitle);
assertEquals("账号: krisolo", row.subtitle);
assertEquals("额度: 暂无 · 状态异常", row.meta);
assertEquals("abnormal", row.statusKey);
}
@@ -177,7 +260,7 @@ public class WechatSurfaceMapperTest {
JSONObject item = new StubJSONObject()
.withString("name", "Mac Studio")
.withString("status", "online")
.withString("account", "17600003315")
.withString("account", "krisolo")
.withString("note", "书房主机")
.withString("endpoint", "https://boss.hyzq.net/device/mac-studio")
.withStringArray("projects", "master-agent", "android-app");
@@ -185,14 +268,14 @@ public class WechatSurfaceMapperTest {
WechatSurfaceMapper.DeviceDetailSummary summary = WechatSurfaceMapper.toDeviceDetailSummary(item);
assertEquals("Mac Studio", summary.title);
assertEquals("账号: 17600003315 · 项目: master-agent / android-app", summary.subtitle);
assertEquals("账号: krisolo · 项目: master-agent / android-app", summary.subtitle);
assertEquals("额度: 暂无 · 书房主机 · https://boss.hyzq.net/device/mac-studio · 项目 master-agent, android-app", summary.meta);
}
@Test
public void rootMeMenuTitles_matchLegacyWechatMenuWithOpsEntry() throws Exception {
assertArrayEquals(
new String[]{"账号与安全", "设置", "运维与修复", "AI 账号", "技能", "关于"},
new String[]{"账号与安全", "设置", "用户与权限", "运维与修复", "AI 账号", "附件与存储", "Telegram 接入", "技能", "关于"},
WechatSurfaceMapper.rootMeMenuTitles()
);
}
@@ -208,7 +291,7 @@ public class WechatSurfaceMapperTest {
@Test
public void mainPage_keepsOpsEntryInStableWechatMenuOrder() throws Exception {
assertArrayEquals(
new String[]{"账号与安全", "设置", "运维与修复", "AI 账号", "技能", "关于"},
new String[]{"账号与安全", "设置", "用户与权限", "运维与修复", "AI 账号", "附件与存储", "Telegram 接入", "技能", "关于"},
WechatSurfaceMapper.rootMeMenuTitles()
);
}
@@ -292,7 +375,7 @@ public class WechatSurfaceMapperTest {
JSONArray devices = new StubObjectArray(
new StubJSONObject()
.withString("id", "device-b")
.withString("account", "17600003315"),
.withString("account", "krisolo"),
new StubJSONObject()
.withString("id", "device-c")
.withString("account", "other-account")
@@ -311,7 +394,7 @@ public class WechatSurfaceMapperTest {
null,
"stale-device-id",
"missing-bound-device",
"17600003315",
"krisolo",
devices
);
@@ -380,15 +463,20 @@ public class WechatSurfaceMapperTest {
public void meMenuItems_useStableKeysInsteadOfDisplayTitlesForRouting() throws Exception {
WechatSurfaceMapper.MeMenuItem[] items = WechatSurfaceMapper.rootMeMenuItems();
assertEquals(6, items.length);
assertEquals(9, items.length);
assertEquals("security", items[0].key);
assertEquals("账号与安全", items[0].title);
assertEquals("settings", items[1].key);
assertEquals("ops", items[2].key);
assertEquals("运维与修复", items[2].title);
assertEquals("ai_accounts", items[3].key);
assertEquals("skills", items[4].key);
assertEquals("about", items[5].key);
assertEquals("access", items[2].key);
assertEquals("用户与权限", items[2].title);
assertEquals("ops", items[3].key);
assertEquals("运维与修复", items[3].title);
assertEquals("ai_accounts", items[4].key);
assertEquals("storage", items[5].key);
assertEquals("附件与存储", items[5].title);
assertEquals("telegram", items[6].key);
assertEquals("skills", items[7].key);
assertEquals("about", items[8].key);
}
@Test

View File

@@ -40,6 +40,17 @@ public class WechatSurfaceMapperTopActionTest {
assertEquals("add_device", action.actionKey);
}
@Test
public void rootTopAction_hidesAddDeviceForSubAccounts() {
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction("devices", false, false, "member");
assertEquals("刷新", action.label);
assertEquals("refresh", action.iconKey);
assertFalse(action.primaryStyle);
assertTrue(action.compactStyle);
assertEquals("refresh", action.actionKey);
}
@Test
public void rootTopAction_keepsRefreshOnMeTab() {
WechatSurfaceMapper.RootTopAction action = WechatSurfaceMapper.rootTopAction("me", true);