feat: ship enterprise control and desktop governance
This commit is contained in:
@@ -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() {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 连着的 PHZ110,PLB110 的无线目标还没有被发现出来。"))
|
||||
.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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, "Mendel(explorer)"));
|
||||
}
|
||||
|
||||
@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<>();
|
||||
|
||||
|
||||
@@ -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), "已处理 update:3"));
|
||||
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().
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user