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:
Yaojia Wang
2026-07-05 16:15:57 +02:00
parent 4871e8ac3d
commit f40b8f9400
52 changed files with 8645 additions and 28 deletions

View 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("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])
}
}