Files
web-terminal/ios/App/WebTermTests/ProjectGroupingTests.swift
Yaojia Wang f40b8f9400 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
2026-07-05 16:15:57 +02:00

212 lines
8.8 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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("filtername 与 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: - sortfavourites 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 groupProjectsother noNamespace
// namespacepublic/projects.ts:163-166 plain
#expect(groups[1].projects == [plain, solo])
}
@Test("groupnamespace 桶大小写不敏感合并,显示名取首见大小写")
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("groupnamespace 组按组内最新 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("displayLabelnamespace 组内剥前缀(大小写不敏感);哨兵组保留全名")
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])
}
}