feat: restore wechat thread ui and group chat

This commit is contained in:
kris
2026-03-28 05:21:44 +08:00
parent afa7e79ad2
commit f0735b31e5
41 changed files with 4091 additions and 578 deletions

View File

@@ -0,0 +1,65 @@
package com.hyzq.boss;
import static org.junit.Assert.assertArrayEquals;
import org.json.JSONObject;
import org.junit.Test;
public class AboutActivityStaleDownloadCleanupTest {
@Test
public void collectStaleDownloadIdsForRemoval_returnsIdsWhenReleaseChanged() throws Exception {
JSONObject availableRelease = new StubJSONObject()
.withString("packageFileName", "boss-android-v1.2.9-release.apk")
.withString("version", "v1.2.9");
long[] ids = AboutActivity.collectStaleDownloadIdsForRemoval(
availableRelease,
"boss-android-v1.2.8-release.apk",
"v1.2.8",
true,
42L,
77L
);
assertArrayEquals(new long[]{42L, 77L}, ids);
}
@Test
public void collectStaleDownloadIdsForRemoval_returnsEmptyWhenReleaseMatchesLocalPackage() throws Exception {
JSONObject availableRelease = new StubJSONObject()
.withString("packageFileName", "boss-android-v1.2.9-release.apk")
.withString("version", "v1.2.9");
long[] ids = AboutActivity.collectStaleDownloadIdsForRemoval(
availableRelease,
"boss-android-v1.2.9-release.apk",
"v1.2.9",
true,
42L,
77L
);
assertArrayEquals(new long[0], ids);
}
private static final class StubJSONObject extends JSONObject {
private final java.util.Map<String, Object> values = new java.util.HashMap<>();
StubJSONObject withString(String key, String value) {
values.put(key, value);
return this;
}
@Override
public String optString(String key) {
Object value = values.get(key);
return value instanceof String ? (String) value : "";
}
@Override
public String optString(String key, String fallback) {
String value = optString(key);
return value.isEmpty() ? fallback : value;
}
}
}

View File

@@ -0,0 +1,131 @@
package com.hyzq.boss;
import org.json.JSONArray;
import org.json.JSONObject;
import org.junit.Test;
import java.util.LinkedHashSet;
import java.util.Set;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
public class GroupCreateActivityTest {
@Test
public void collectSelectableConversationItems_filtersOutExistingGroupChats() {
JSONObject threadConversation = new StubJSONObject()
.withString("projectId", "thread-1")
.withString("projectTitle", "线程一")
.withBoolean("isGroup", false);
JSONObject groupConversation = new StubJSONObject()
.withString("projectId", "group-1")
.withString("projectTitle", "已有群聊")
.withBoolean("isGroup", true);
JSONObject sourceConversation = new StubJSONObject()
.withString("projectId", "source-1")
.withString("projectTitle", "来源线程")
.withBoolean("isGroup", false);
JSONObject conversationsPayload = new StubJSONObject()
.withObjectArray("conversations", threadConversation, groupConversation, sourceConversation);
java.util.List<JSONObject> filtered = GroupCreateActivity.collectSelectableConversationItems(conversationsPayload, "source-1");
assertEquals(1, filtered.size());
assertEquals("thread-1", filtered.get(0).optString("projectId", ""));
}
@Test
public void reconcileSelectedProjectIds_keepsManualDeselectionWhenCandidatesStayTheSame() {
Set<String> previousCandidateIds = linkedSet("thread-1", "thread-2", "thread-3");
Set<String> currentSelectedIds = linkedSet("thread-1", "thread-3");
Set<String> nextCandidateIds = linkedSet("thread-1", "thread-2", "thread-3");
Set<String> reconciled = GroupCreateActivity.reconcileSelectedProjectIds(
currentSelectedIds,
previousCandidateIds,
nextCandidateIds
);
assertEquals(2, reconciled.size());
assertTrue(reconciled.contains("thread-1"));
assertTrue(reconciled.contains("thread-3"));
assertFalse(reconciled.contains("thread-2"));
}
@Test
public void canCreateGroupChat_blocksWhileRefreshingOrCreating() {
Set<String> selectedProjectIds = linkedSet("thread-1");
assertFalse(GroupCreateActivity.canCreateGroupChat(true, false, selectedProjectIds));
assertFalse(GroupCreateActivity.canCreateGroupChat(false, true, selectedProjectIds));
assertTrue(GroupCreateActivity.canCreateGroupChat(false, false, selectedProjectIds));
assertFalse(GroupCreateActivity.canCreateGroupChat(false, false, linkedSet()));
}
private static Set<String> linkedSet(String... values) {
Set<String> result = new LinkedHashSet<>();
for (String value : values) {
result.add(value);
}
return result;
}
private static final class StubJSONObject extends JSONObject {
private final java.util.Map<String, Object> values = new java.util.HashMap<>();
StubJSONObject withString(String key, String value) {
values.put(key, value);
return this;
}
StubJSONObject withBoolean(String key, boolean value) {
values.put(key, value);
return this;
}
StubJSONObject withObjectArray(String key, JSONObject... entries) {
values.put(key, new StubJSONArray(entries));
return this;
}
@Override
public String optString(String key, String defaultValue) {
Object value = values.get(key);
return value instanceof String ? (String) value : defaultValue;
}
@Override
public boolean optBoolean(String key, boolean defaultValue) {
Object value = values.get(key);
return value instanceof Boolean ? (Boolean) value : defaultValue;
}
@Override
public JSONArray optJSONArray(String key) {
Object value = values.get(key);
return value instanceof JSONArray ? (JSONArray) value : null;
}
}
private static final class StubJSONArray extends JSONArray {
private final JSONObject[] entries;
StubJSONArray(JSONObject... entries) {
this.entries = entries == null ? new JSONObject[0] : entries;
}
@Override
public int length() {
return entries.length;
}
@Override
public JSONObject optJSONObject(int index) {
if (index < 0 || index >= entries.length) {
return null;
}
return entries[index];
}
}
}

View File

@@ -1,43 +1,102 @@
package com.hyzq.boss;
import org.junit.Test;
import org.json.JSONArray;
import org.json.JSONObject;
import static org.junit.Assert.assertArrayEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
public class WechatSurfaceMapperTest {
@Test
public void toConversationRow_keepsOnlyWechatFields() throws Exception {
public void toConversationRow_mapsWechatConversationFieldsFromThreadPayload() throws Exception {
JSONObject item = new StubJSONObject()
.withString("projectTitle", "项目 A")
.withString("preview", "最近消息预览")
.withString("latestReplyLabel", "10:24")
.withString("threadTitle", "北区试产线回归")
.withString("folderLabel", "归档确认")
.withString("preview", "旧预览")
.withString("lastMessagePreview", "现场摄像头关键帧")
.withString("latestReplyLabel", "09:26")
.withInt("unreadCount", 3)
.withString("deviceNamesPreview", "Mac Studio")
.withBoolean("contextBudgetIndicator", true);
.withInt("activityIconCount", 2)
.withString("topPinnedLabel", "置顶")
.withBoolean("isGroup", false)
.withObject("avatar", new StubJSONObject()
.withString("primary", "M")
.withString("secondary", "W"))
.withObjectArray("groupMembers",
new StubJSONObject().withString("threadId", "t-1").withString("avatar", "M").withString("title", "Mac Studio"),
new StubJSONObject().withString("threadId", "t-2").withString("avatar", "W").withString("title", "Windows GPU"));
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
assertEquals("项目 A", row.title);
assertEquals("最近消息预览", row.preview);
assertEquals("10:24", row.timeLabel);
assertEquals("北区试产线回归", row.threadTitle);
assertEquals("归档确认", row.folderLabel);
assertEquals("现场摄像头关键帧", row.lastMessagePreview);
assertEquals("09:26", row.timeLabel);
assertEquals(3, row.unreadCount);
assertEquals("置顶", row.topPinnedLabel);
assertEquals(2, row.activityIconCount);
assertFalse(row.isGroup);
assertEquals("M", row.avatarPrimary);
assertEquals("W", row.avatarSecondary);
assertEquals(2, row.groupAvatarMembers.length);
assertEquals("Mac Studio", row.groupAvatarMembers[0].title);
assertEquals("W", row.groupAvatarMembers[1].avatarLabel);
}
@Test
public void toDeviceRow_keepsOnlySimpleSubtitle() throws Exception {
public void toConversationRow_prefersGroupMembersForGroupAvatarSummary() throws Exception {
JSONObject item = new StubJSONObject()
.withString("projectTitle", "群聊项目")
.withString("threadTitle", "容灾切换验证")
.withString("folderLabel", "Mac + Windows 协作")
.withString("lastMessagePreview", "最新: API 切换记录回传")
.withString("latestReplyLabel", "09:12")
.withInt("activityIconCount", 2)
.withString("topPinnedLabel", "置顶")
.withBoolean("isGroup", true)
.withObjectArray("groupMembers",
new StubJSONObject().withString("threadId", "group-1").withString("avatar", "M").withString("title", "Mac Studio"),
new StubJSONObject().withString("threadId", "group-2").withString("avatar", "W").withString("title", "Windows GPU"),
new StubJSONObject().withString("threadId", "group-3").withString("avatar", "C").withString("title", "Cloud Backup"));
WechatSurfaceMapper.ConversationRow row = WechatSurfaceMapper.toConversationRow(item);
assertTrue(row.isGroup);
assertEquals("容灾切换验证", row.threadTitle);
assertEquals("Mac + Windows 协作", row.folderLabel);
assertEquals("最新: API 切换记录回传", row.lastMessagePreview);
assertEquals("09:12", row.timeLabel);
assertEquals(3, row.groupAvatarMembers.length);
assertEquals("M", row.groupAvatarMembers[0].avatarLabel);
assertEquals("Cloud Backup", row.groupAvatarMembers[2].title);
assertEquals("", row.avatarPrimary);
}
@Test
public void toDeviceRow_mapsLegacyWechatThreeLineSummary() throws Exception {
JSONObject item = new StubJSONObject()
.withString("name", "Mac Studio")
.withString("avatar", "M")
.withString("status", "online")
.withString("account", "17600003315")
.withStringArray("projects", "北区试产线回归", "容灾切换验证")
.withInt("quota5h", 8)
.withInt("quota7d", 22);
WechatSurfaceMapper.DeviceRow row = WechatSurfaceMapper.toDeviceRow(item);
assertEquals("Mac Studio", row.title);
assertEquals("在线 · 17600003315", row.subtitle);
assertEquals("账号: 17600003315 · 项目: 北区试产线回归 / 容灾切换验证", row.subtitle);
assertEquals("额度: 5h 8% · 7d 22%", row.meta);
assertEquals("M", row.avatarLabel);
assertEquals("online", row.statusKey);
}
@Test
@@ -50,7 +109,9 @@ public class WechatSurfaceMapperTest {
WechatSurfaceMapper.DeviceRow row = WechatSurfaceMapper.toDeviceRow(item);
assertEquals("Mac Studio", row.title);
assertEquals("异常 · 17600003315", row.subtitle);
assertEquals("账号: 17600003315", row.subtitle);
assertEquals("额度: 暂无 · 状态异常", row.meta);
assertEquals("abnormal", row.statusKey);
}
@Test
@@ -61,19 +122,19 @@ public class WechatSurfaceMapperTest {
.withString("account", "17600003315")
.withString("note", "书房主机")
.withString("endpoint", "https://boss.hyzq.net/device/mac-studio")
.withArray("projects", "master-agent", "android-app");
.withStringArray("projects", "master-agent", "android-app");
WechatSurfaceMapper.DeviceDetailSummary summary = WechatSurfaceMapper.toDeviceDetailSummary(item);
assertEquals("Mac Studio", summary.title);
assertEquals("在线 · 17600003315", summary.subtitle);
assertEquals("书房主机 · https://boss.hyzq.net/device/mac-studio · 项目 master-agent, android-app", summary.meta);
assertEquals("账号: 17600003315 · 项目: master-agent / android-app", summary.subtitle);
assertEquals("额度: 暂无 · 书房主机 · https://boss.hyzq.net/device/mac-studio · 项目 master-agent, android-app", summary.meta);
}
@Test
public void rootMeMenuTitles_matchApprovedSimpleMenu() throws Exception {
public void rootMeMenuTitles_matchLegacyWechatMenuWithOpsEntry() throws Exception {
assertArrayEquals(
new String[]{"账号与安全", "AI 账号", "设置", "技能", "关于"},
new String[]{"账号与安全", "设置", "运维与修复", "AI 账号", "技能", "关于"},
WechatSurfaceMapper.rootMeMenuTitles()
);
}
@@ -87,16 +148,112 @@ public class WechatSurfaceMapperTest {
}
@Test
public void mainPage_doesNotExposeOpsEntry() throws Exception {
public void mainPage_keepsOpsEntryInStableWechatMenuOrder() throws Exception {
assertArrayEquals(
new String[]{"账号与安全", "AI 账号", "设置", "技能", "关于"},
new String[]{"账号与安全", "设置", "运维与修复", "AI 账号", "技能", "关于"},
WechatSurfaceMapper.rootMeMenuTitles()
);
}
@Test
public void advancedEntryTitle_movesOpsOutOfMainMePage() throws Exception {
assertEquals("高级与调试", WechatSurfaceMapper.advancedEntryTitle());
public void opsEntryCopy_staysInMeFlowWithoutLegacyAdvancedEntrySemantics() throws Exception {
WechatSurfaceMapper.MeMenuItem opsItem = WechatSurfaceMapper.findMeMenuItem("ops");
assertNotNull(opsItem);
assertEquals("运维与修复", opsItem.title);
assertEquals("查看运维会话、修复回放与 standby 切换", opsItem.description);
}
@Test
public void meFlow_doesNotExposeAuditConversationCopy() throws Exception {
for (WechatSurfaceMapper.MeMenuItem item : WechatSurfaceMapper.rootMeMenuItems()) {
assertFalse(item.title.contains("审计"));
assertFalse(item.description.contains("审计"));
}
}
@Test
public void aboutActivity_parsesStructuredOtaSummaryArrayIntoReadableContent() throws Exception {
JSONObject ota = new StubJSONObject()
.withObject("availableRelease", new StubJSONObject()
.withString("version", "v1.2.8")
.withStringArray("summary", "优化设备状态刷新", "修复主 Agent 会话排序", "提升 OTA 回收稳定性"));
java.lang.reflect.Method method = AboutActivity.class.getDeclaredMethod("buildOtaContentBody", JSONObject.class);
method.setAccessible(true);
String content = (String) method.invoke(null, ota);
assertEquals("版本 v1.2.8\n1. 优化设备状态刷新\n2. 修复主 Agent 会话排序\n3. 提升 OTA 回收稳定性", content);
}
@Test
public void aboutActivity_rejectsStaleDownloadedApkWhenAvailableReleaseChanged() throws Exception {
JSONObject availableRelease = new StubJSONObject()
.withString("version", "v1.2.9")
.withString("packageFileName", "boss-android-v1.2.9-release.apk");
java.lang.reflect.Method method = AboutActivity.class.getDeclaredMethod(
"isDownloadedReleaseCurrent",
JSONObject.class,
String.class,
String.class
);
method.setAccessible(true);
boolean stillCurrent = (Boolean) method.invoke(
null,
availableRelease,
"boss-android-v1.2.8-release.apk",
"v1.2.8"
);
assertFalse(stillCurrent);
}
@Test
public void skillInventory_fallsBackWhenExplicitDeviceIdIsInvalid() throws Exception {
JSONArray devices = new StubObjectArray(
new StubJSONObject()
.withString("id", "device-b")
.withString("account", "17600003315"),
new StubJSONObject()
.withString("id", "device-c")
.withString("account", "other-account")
);
java.lang.reflect.Method method = SkillInventoryActivity.class.getDeclaredMethod(
"chooseTargetDeviceId",
String.class,
String.class,
String.class,
JSONArray.class
);
method.setAccessible(true);
String resolved = (String) method.invoke(
null,
"stale-device-id",
"missing-bound-device",
"17600003315",
devices
);
assertEquals("device-b", resolved);
}
@Test
public void aboutActivity_collectsAllPositiveDownloadIdsForStaleRemoval() throws Exception {
java.lang.reflect.Method method = AboutActivity.class.getDeclaredMethod(
"collectDownloadIdsForRemoval",
long.class,
long.class
);
method.setAccessible(true);
long[] ids = (long[]) method.invoke(null, 42L, 77L);
assertArrayEquals(new long[]{42L, 77L}, ids);
}
@Test
@@ -107,6 +264,16 @@ public class WechatSurfaceMapperTest {
);
}
@Test
public void projectDetailInfoTarget_routesSingleThreadsToConversationInfo() {
assertEquals(ConversationInfoActivity.class, WechatSurfaceMapper.resolveConversationInfoTargetClass(false));
}
@Test
public void projectDetailInfoTarget_routesGroupChatsToGroupInfo() {
assertEquals(GroupInfoActivity.class, WechatSurfaceMapper.resolveConversationInfoTargetClass(true));
}
@Test
public void projectPrimarySections_keepOnlyChatEssentials() throws Exception {
assertArrayEquals(
@@ -115,6 +282,66 @@ public class WechatSurfaceMapperTest {
);
}
@Test
public void conversationsChrome_copyStaysLightweightInsteadOfConsoleInstructions() throws Exception {
assertEquals("会话", WechatSurfaceMapper.loginTitle());
assertEquals("进入会话", WechatSurfaceMapper.loginButtonLabel());
assertEquals("项目自动对应设备 GUI 项目文件夹", WechatSurfaceMapper.conversationsHintPillText());
assertEquals("", WechatSurfaceMapper.conversationsHeaderSubtitle());
assertFalse(WechatSurfaceMapper.loginHintText().contains("Boss API"));
assertFalse(WechatSurfaceMapper.loginHintText().contains("控制台"));
assertFalse(WechatSurfaceMapper.loginHintText().contains("数据仍来自现有 Boss API"));
}
@Test
public void conversationActivityIcons_useAnimatedDotViewsInsteadOfTextGlyphs() throws Exception {
assertEquals("animated_dots", WechatSurfaceMapper.conversationActivityIconMode());
assertEquals(4, WechatSurfaceMapper.maxConversationActivityIcons());
assertEquals("cancel_on_detach", WechatSurfaceMapper.conversationActivityAnimationCleanup());
}
@Test
public void meMenuItems_useStableKeysInsteadOfDisplayTitlesForRouting() throws Exception {
WechatSurfaceMapper.MeMenuItem[] items = WechatSurfaceMapper.rootMeMenuItems();
assertEquals(6, 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);
}
@Test
public void refreshMergePolicy_appliesSuccessfulPayloadsWithoutDroppingCachedValues() throws Exception {
JSONArray cachedConversations = new StubStringArray("cached-conversation");
JSONArray freshConversations = new StubStringArray("fresh-conversation");
JSONArray cachedDevices = new StubStringArray("cached-device");
JSONObject cachedOta = new StubJSONObject().withString("version", "1.0.0");
JSONObject freshSettings = new StubJSONObject().withString("preferredEntryPoint", "devices");
assertSame(
freshConversations,
WechatSurfaceMapper.resolveRefreshValue(cachedConversations, freshConversations, true)
);
assertSame(
cachedDevices,
WechatSurfaceMapper.resolveRefreshValue(cachedDevices, new StubStringArray("fresh-device"), false)
);
assertSame(
cachedOta,
WechatSurfaceMapper.resolveRefreshValue(cachedOta, new StubJSONObject().withString("version", "2.0.0"), false)
);
assertSame(
freshSettings,
WechatSurfaceMapper.resolveRefreshValue(null, freshSettings, true)
);
assertNull(WechatSurfaceMapper.resolveRefreshValue(null, null, false));
}
private static final class StubJSONObject extends JSONObject {
private final java.util.Map<String, Object> values = new java.util.HashMap<>();
@@ -133,8 +360,18 @@ public class WechatSurfaceMapperTest {
return this;
}
StubJSONObject withArray(String key, String... entries) {
values.put(key, new StubJSONArray(entries));
StubJSONObject withObject(String key, JSONObject value) {
values.put(key, value);
return this;
}
StubJSONObject withStringArray(String key, String... entries) {
values.put(key, new StubStringArray(entries));
return this;
}
StubJSONObject withObjectArray(String key, JSONObject... entries) {
values.put(key, new StubObjectArray(entries));
return this;
}
@@ -157,16 +394,22 @@ public class WechatSurfaceMapperTest {
}
@Override
public org.json.JSONArray optJSONArray(String key) {
public JSONObject optJSONObject(String key) {
Object value = values.get(key);
return value instanceof org.json.JSONArray ? (org.json.JSONArray) value : null;
return value instanceof JSONObject ? (JSONObject) value : null;
}
@Override
public JSONArray optJSONArray(String key) {
Object value = values.get(key);
return value instanceof JSONArray ? (JSONArray) value : null;
}
}
private static final class StubJSONArray extends org.json.JSONArray {
private static final class StubStringArray extends JSONArray {
private final String[] entries;
StubJSONArray(String... entries) {
StubStringArray(String... entries) {
this.entries = entries == null ? new String[0] : entries;
}
@@ -180,8 +423,28 @@ public class WechatSurfaceMapperTest {
if (index < 0 || index >= entries.length) {
return "";
}
String value = entries[index];
return value == null ? "" : value;
return entries[index] == null ? "" : entries[index];
}
}
private static final class StubObjectArray extends JSONArray {
private final JSONObject[] entries;
StubObjectArray(JSONObject... entries) {
this.entries = entries == null ? new JSONObject[0] : entries;
}
@Override
public int length() {
return entries.length;
}
@Override
public JSONObject optJSONObject(int index) {
if (index < 0 || index >= entries.length) {
return null;
}
return entries[index];
}
}
}