Files
hapi/ios/HapiTests/SessionLifecycleActionTests.swift
T
weishuandClaude Opus 5.5 5942b427a9 feat(ios): rename, delete and reopen sessions
The API wrappers existed but iOS had no UI for these actions, while
web and Android did. The session row's context menu and the chat's …
menu now offer Rename for every session. Active sessions keep Archive.
Inactive sessions get Reopen and a confirmed Delete instead of
Archive, which the hub rejects with 409 for them.

The store applies renames and deletes optimistically. A failed rename
refetches server state; a failed delete restores the row and the
cached detail. Reopen marks the returned session active right away.
When reopen returns a superseding id, the chat seeds the new window,
moves the draft and navigates to it, as send-resume already does. A
reopen from the list also moves the draft and opens the session. A
422 shows the hub's message with the missing fields.

On iPhone, an authoritative removal (delete, or archive elsewhere) now
pops that chat, matching the iPad split, which already clears the
selection.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 18:00:00 +08:00

92 lines
3.7 KiB
Swift

import HapiClient
import XCTest
@testable import Hapi
@testable import HapiProtocol
/// Home-list rename/delete/reopen forwarding over in-memory stores: no
/// networking, Keychain or disk snapshots.
@MainActor
final class SessionLifecycleActionTests: XCTestCase {
private final class Drafts: ChatDrafts {
var storage: [String: String] = [:]
func load(sessionId: String) -> String? { storage[sessionId] }
func save(sessionId: String, text: String) { storage[sessionId] = text }
func clear(sessionId: String) { storage[sessionId] = nil }
func move(fromSessionId: String, toSessionId: String) {
storage[toSessionId] = storage.removeValue(forKey: fromSessionId)
}
}
private func makeModel(_ sessions: HomeFilterTestSessions, drafts: Drafts? = nil) -> SessionListModel {
SessionListModel(
sessionStore: sessions,
machineStore: HomeFilterTestMachines(),
lastSeenStore: LastSeenStore(),
hubUrl: "test",
drafts: drafts
)
}
private func waitUntil(_ condition: @MainActor () -> Bool, file: StaticString = #filePath, line: UInt = #line) async {
for _ in 0..<200 where !condition() {
try? await Task.sleep(for: .milliseconds(10))
}
XCTAssertTrue(condition(), file: file, line: line)
}
func testRenameTrimsAndIgnoresBlankNames() async {
let sessions = HomeFilterTestSessions([HomeFilterTestData.summary("s1", machine: "m1")])
let model = makeModel(sessions)
model.renameSession(sessionId: "s1", name: " ")
model.renameSession(sessionId: "s1", name: " Release prep ")
await waitUntil { sessions.sessions.first?.metadata?.name == "Release prep" }
XCTAssertTrue(SessionNameRules.isValid(" x "))
XCTAssertFalse(SessionNameRules.isValid(" "))
XCTAssertFalse(SessionNameRules.isValid(String(repeating: "a", count: 256)))
}
func testDeleteConflictAsksToArchiveFirst() async {
let sessions = HomeFilterTestSessions([HomeFilterTestData.summary("s1", machine: "m1")])
sessions.deleteError = APIError(status: 409, code: "Cannot delete active session. Archive it first.")
let model = makeModel(sessions)
model.deleteSession(sessionId: "s1")
await waitUntil { model.actionError != nil }
XCTAssertEqual(model.actionError, String(localized: "Active sessions can't be deleted — archive first"))
XCTAssertEqual(sessions.sessions.map(\.id), ["s1"])
}
func testReopenFollowsASupersedingSessionWithItsDraft() async {
let sessions = HomeFilterTestSessions([HomeFilterTestData.summary("old", machine: "m1")])
sessions.reopenTarget = "new"
let drafts = Drafts()
drafts.storage["old"] = "unsent"
let model = makeModel(sessions, drafts: drafts)
var opened: [String] = []
model.reopenSession(sessionId: "old") { opened.append($0) }
await waitUntil { opened == ["new"] }
XCTAssertEqual(drafts.storage, ["new": "unsent"])
}
func testReopenFailureShowsTheHubMessage() async {
let sessions = HomeFilterTestSessions([HomeFilterTestData.summary("old", machine: "m1")])
sessions.reopenError = APIError(
status: 422,
body: #"{"error":"Session metadata is incomplete","missing":["path"]}"#
)
let model = makeModel(sessions)
var opened: [String] = []
model.reopenSession(sessionId: "old") { opened.append($0) }
await waitUntil { model.actionError != nil }
XCTAssertEqual(model.actionError, "Session metadata is incomplete (missing: path)")
XCTAssertTrue(opened.isEmpty)
}
}