feat(ios): P1-B — W7 UI wave: deep links, timeline, quick-reply, diff, projects, session switcher, thumbnails, lock-screen push
T-iOS-22: DeepLinkRouter (full-field whitelist, cold-start stash, route(from:) for push-tap reuse), 21 tests T-iOS-24: TimelineSheet mirroring web render() order; disabled→empty-state; reuses AwayDigest onExpand T-iOS-25: QuickReply chips (built-ins mirror quick-reply.ts via KeyByteMap; visible iff live gate && !readOnly) T-iOS-27: read-only DiffScreen + App-layer DiffFetcher (RO no-Origin; APIClient fold-in noted for T-iOS-38 owner) T-iOS-26: Projects list/detail — grouping byte-identical to web group keys (prefs-shared collapse state), prefs-clobber defenses (no blind PUT on empty base; adopt server echo), claude\r bootstrap T-iOS-23: UnreadLedger + TitleSanitizer (SessionCore, +15 tests), lastOutputAt decode, list-boundary re-sanitize T-iOS-29: new-in-cwd (untrusted cwd, no bootstrap re-injection) + exited-session reopen; fixes stale-controller SwiftTerm view bug via .id(controller.id) T-iOS-28: offscreen SwiftTerm thumbnail pipeline (LRU 32, concurrency gate 2, 256KiB cap, grid clamp) T-iOS-21: PushRegistrar (WEBTERM_GATE category: Allow=.authenticationRequired, no .foreground) + NotificationActionHandler (whitelisted payload, token never persisted, bg-task-wrapped POST, 403 fallback) CRITICAL fix (verify-found boot crash): @Sendable literals on UN completion closures — MainActor-inherited closures trapped Swift 6 executor check on UN's background queue; boot re-verified (no new crash reports, permission prompt reached, privacy shade correctly covering during system alert) Verified: 261 pkg + 247 app + 10 integration tests green; 7/7 semantics checks; Owns audit clean
This commit is contained in:
211
ios/App/WebTermTests/ProjectGroupingTests.swift
Normal file
211
ios/App/WebTermTests/ProjectGroupingTests.swift
Normal file
@@ -0,0 +1,211 @@
|
||||
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])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user