import APIClient import Foundation import Testing @testable import WebTerm /// T-iOS-26 · Projects 列表的纯分组逻辑 —— 逐条镜像 web v0.6 的 /// public/projects.ts 规则(filterProjects / sortProjects / groupProjects / /// displayLabel): /// - namespace = 名字的前两个点分段(不足两段 → 无 namespace); /// - 成员 < MIN_GROUP_SIZE(2) 的 namespace 塌进 Other; /// - 有运行中会话的项目**复制**进置顶的 "Active now" 组(sessions[].exited /// 字段实测存在 —— active 置顶按任务要求 assert reality 后实现); /// - 一个 namespace 组都没有 → 单一 flat 组(无 chrome 的平铺网格回退); /// - 组 key 与 web 逐字节一致(" active" / " other" / namespace 原始大小写), /// 因为 collapsed 状态经 /prefs 跨端共享,key 不一致就互相丢状态。 struct ProjectGroupingTests { // MARK: - Fixtures private static func session(exited: Bool) -> ProjectSessionRef { ProjectSessionRef( id: UUID(), title: nil, status: .working, clientCount: 1, createdAt: 0, exited: exited ) } private static func project( _ name: String, path: String? = nil, lastActiveMs: Int? = nil, running: Bool = false, exitedSession: Bool = false ) -> ProjectInfo { var sessions: [ProjectSessionRef] = [] if running { sessions.append(session(exited: false)) } if exitedSession { sessions.append(session(exited: true)) } return ProjectInfo( name: name, path: path ?? "/repos/\(name)", isGit: true, branch: "main", dirty: nil, lastActiveMs: lastActiveMs, sessions: sessions ) } // MARK: - filter(大小写不敏感的 name/path 子串,镜像 filterProjects) @Test("filter:空查询原样返回全部(含前后空白仅剩空白的查询)") func filterEmptyQueryReturnsAll() { let projects = [Self.project("a"), Self.project("b")] #expect(ProjectGrouping.filter(projects, query: "") == projects) #expect(ProjectGrouping.filter(projects, query: " ") == projects) } @Test("filter:name 与 path 子串均命中,大小写不敏感") func filterMatchesNameAndPathCaseInsensitively() { let byName = Self.project("Billo.Platform.api", path: "/x/one") let byPath = Self.project("zzz", path: "/Users/dev/BILLO-extras") let miss = Self.project("other", path: "/y/two") let result = ProjectGrouping.filter([byName, byPath, miss], query: "billo") #expect(result == [byName, byPath]) } // MARK: - sort(favourites 优先 → lastActiveMs 降序 → 输入序稳定) @Test("sort:收藏优先,其余按 lastActiveMs 降序,缺失视为 0") func sortFavouritesFirstThenRecency() { let fav = Self.project("fav", lastActiveMs: 1) let newer = Self.project("newer", lastActiveMs: 100) let older = Self.project("older", lastActiveMs: 50) let never = Self.project("never", lastActiveMs: nil) let result = ProjectGrouping.sort( [never, older, fav, newer], favourites: [fav.path] ) #expect(result == [fav, newer, older, never]) } @Test("sort:全键相等时保持输入顺序(稳定排序,镜像 JS stable sort)") func sortIsStableOnTies() { let a = Self.project("a", lastActiveMs: 5) let b = Self.project("b", lastActiveMs: 5) let c = Self.project("c", lastActiveMs: 5) #expect(ProjectGrouping.sort([a, b, c], favourites: []) == [a, b, c]) } // MARK: - namespace 分组 @Test("group:≥2 成员的 namespace 成组,单成员与无点名塌进 Other") func groupNamespacesAndOther() { let apiProj = Self.project("Billo.Platform.api", lastActiveMs: 2) let webProj = Self.project("Billo.Platform.web", lastActiveMs: 1) let solo = Self.project("solo.thing") let plain = Self.project("plain") let groups = ProjectGrouping.group( [apiProj, webProj, solo, plain], favourites: [] ) #expect(groups.count == 2) #expect(groups[0].kind == .namespace) #expect(groups[0].key == "Billo.Platform") #expect(groups[0].label == "Billo.Platform") #expect(groups[0].projects == [apiProj, webProj]) #expect(groups[1].kind == .other) #expect(groups[1].key == ProjectGrouping.otherGroupKey) // 镜像 web groupProjects:other 先收无点名(noNamespace),再追加 // 塌掉的单成员 namespace(public/projects.ts:163-166)→ plain 在前。 #expect(groups[1].projects == [plain, solo]) } @Test("group:namespace 桶大小写不敏感合并,显示名取首见大小写") func groupBucketsCaseInsensitively() { let first = Self.project("Billo.Platform.a") let second = Self.project("billo.platform.b") let groups = ProjectGrouping.group([first, second], favourites: []) #expect(groups.count == 1) #expect(groups[0].key == "Billo.Platform") #expect(groups[0].projects == [first, second]) } @Test("group:没有任何 namespace 组 → 单一 flat 组(全部项目,无 chrome 回退)") func groupFlatFallback() { let solo = Self.project("solo.thing", lastActiveMs: 1) let plain = Self.project("plain", lastActiveMs: 2) let groups = ProjectGrouping.group([solo, plain], favourites: []) #expect(groups.count == 1) #expect(groups[0].kind == .flat) #expect(groups[0].key == ProjectGrouping.otherGroupKey) #expect(groups[0].projects == [plain, solo]) // recency 降序 #expect(groups[0].isCollapsible == false) } @Test("group:运行中的项目复制进置顶 Active 组;activeCount 逐组统计") func groupActivePinning() { let runningA = Self.project("Billo.Platform.api", lastActiveMs: 2, running: true) let idleB = Self.project("Billo.Platform.web", lastActiveMs: 1) let exitedOnly = Self.project("Billo.Platform.cli", exitedSession: true) let groups = ProjectGrouping.group( [runningA, idleB, exitedOnly], favourites: [] ) #expect(groups.count == 2) #expect(groups[0].kind == .active) #expect(groups[0].key == ProjectGrouping.activeGroupKey) #expect(groups[0].projects == [runningA]) // exited 会话不算 running #expect(groups[0].activeCount == 1) #expect(groups[1].kind == .namespace) #expect(groups[1].projects.contains(runningA)) // 复制而非移动 #expect(groups[1].activeCount == 1) #expect(groups[0].isCollapsible == false) // Active now 永远展开 #expect(groups[1].isCollapsible == true) } @Test("group:namespace 组按组内最新 lastActiveMs 降序,平局按 label 升序") func groupOrdersNamespacesByRecencyThenLabel() { let older1 = Self.project("Aaa.Team.x", lastActiveMs: 10) let older2 = Self.project("Aaa.Team.y", lastActiveMs: 20) let newer1 = Self.project("Zzz.Team.x", lastActiveMs: 5) let newer2 = Self.project("Zzz.Team.y", lastActiveMs: 99) let tieA1 = Self.project("Bbb.Tie.x", lastActiveMs: 20) let tieA2 = Self.project("Bbb.Tie.y", lastActiveMs: 3) let groups = ProjectGrouping.group( [older1, older2, newer1, newer2, tieA1, tieA2], favourites: [] ) #expect(groups.map(\.key) == ["Zzz.Team", "Aaa.Team", "Bbb.Tie"]) } // MARK: - displayLabel(组内卡片名去掉 namespace 前缀) @Test("displayLabel:namespace 组内剥前缀(大小写不敏感);哨兵组保留全名") func displayLabelStripsNamespacePrefix() { #expect(ProjectGrouping.displayLabel( name: "Billo.Platform.api", groupKey: "Billo.Platform" ) == "api") #expect(ProjectGrouping.displayLabel( name: "billo.platform.api", groupKey: "Billo.Platform" ) == "api") #expect(ProjectGrouping.displayLabel( name: "unrelated", groupKey: "Billo.Platform" ) == "unrelated") #expect(ProjectGrouping.displayLabel( name: "Billo.Platform.api", groupKey: ProjectGrouping.activeGroupKey ) == "Billo.Platform.api") #expect(ProjectGrouping.displayLabel( name: "Billo.Platform.api", groupKey: ProjectGrouping.otherGroupKey ) == "Billo.Platform.api") } @Test("group:组内排序收藏优先(favourites 参与 sortProjects)") func groupSortsFavouritesFirstWithinGroup() { let apiProj = Self.project("Ns.Grp.api", lastActiveMs: 9) let webProj = Self.project("Ns.Grp.web", lastActiveMs: 1) let groups = ProjectGrouping.group( [apiProj, webProj], favourites: [webProj.path] ) #expect(groups.count == 1) #expect(groups[0].projects == [webProj, apiProj]) } }