Files
openclaw-openclaw/apps/ios/Tests/CommandSessionGroupingTests.swift
Peter Steinberger 4c97aa1311 feat(mobile): rename, delete, and create session groups on iOS and Android (#101234)
* feat(mobile): rename, delete, and create sidebar session groups on iOS and Android

Group section headers on the iOS Command Center sessions screen and the
Android sessions screen gain Rename/New/Delete group actions. Bulk ops
enumerate every member (active + archived, explicit high list limit since
the gateway caps an absent limit at 100 rows) and patch category per
session; delete keeps sessions and moves them to Ungrouped. Known custom
groups persist client-side (iOS UserDefaults, Android SharedPreferences)
so empty groups stay visible as move targets.

* chore(i18n): refresh native string inventory after rebase onto main
2026-07-07 01:09:57 +01:00

180 lines
6.7 KiB
Swift

import Foundation
import OpenClawChatUI
import Testing
@testable import OpenClaw
struct CommandSessionGroupingTests {
@Test func `groups pinned categories and ungrouped in display order`() {
let sections = CommandSessionGrouping.sections(from: [
self.entry("ungrouped", activity: 2),
self.entry("beta", category: "Beta", activity: 3),
self.entry("alpha-old", category: "Alpha", activity: 1),
self.entry("pinned", category: "Beta", pinned: true, activity: 4),
self.entry("alpha-new", category: "Alpha", activity: 5),
])
#expect(sections.map(\.id) == [
.pinned,
.category("Alpha"),
.category("Beta"),
.ungrouped,
])
#expect(sections[0].entries.map(\.key) == ["pinned"])
#expect(sections[1].entries.map(\.key) == ["alpha-new", "alpha-old"])
#expect(sections[3].showsHeader)
}
@Test func `known groups render empty sections in alphabetical merge`() {
let sections = CommandSessionGrouping.sections(
from: [
self.entry("beta", category: "Beta", activity: 2),
self.entry("plain", activity: 1),
],
knownGroups: ["Zulu", "Alpha"])
#expect(sections.map(\.id) == [
.category("Alpha"),
.category("Beta"),
.category("Zulu"),
.ungrouped,
])
#expect(sections[0].entries.isEmpty)
#expect(sections[1].entries.map(\.key) == ["beta"])
#expect(sections[2].entries.isEmpty)
#expect(sections[3].showsHeader)
}
@Test func `known groups ignore blanks and duplicates`() {
let sections = CommandSessionGrouping.sections(
from: [self.entry("beta", category: "Beta", activity: 1)],
knownGroups: [" ", "Beta", "Beta", "Alpha"])
#expect(sections.map(\.id) == [.category("Alpha"), .category("Beta")])
let categories = CommandSessionGrouping.categories(
from: [self.entry("beta", category: "Beta", activity: 1)],
knownGroups: ["", "Beta", "Alpha", "Alpha"])
#expect(categories == ["Alpha", "Beta"])
}
@Test func `group members merge active and archived lists deduped by key`() {
let members = CommandSessionGrouping.members(
of: "Ops",
in: [
[
self.entry("a", category: "Ops", activity: 1),
self.entry("other", category: "Dev", activity: 2),
],
[
self.entry("a", category: "Ops", activity: 1),
self.entry("b", category: " Ops ", activity: 3),
self.entry("plain", activity: 4),
],
])
#expect(members.map(\.key) == ["a", "b"])
#expect(CommandSessionGrouping.members(of: " ", in: [[self.entry("a", activity: 1)]]).isEmpty)
}
@Test func `hides ungrouped header without category sections`() {
let sections = CommandSessionGrouping.sections(from: [self.entry("plain", activity: 1)])
#expect(sections.count == 1)
#expect(sections[0].id == .ungrouped)
#expect(!sections[0].showsHeader)
}
@Test func `preview puts pinned sessions before recent activity`() {
let entries = CommandSessionGrouping.previewOrder([
self.entry("recent", activity: 20),
self.entry("pinned-old", pinned: true, activity: 1),
self.entry("older", activity: 10),
])
#expect(entries.map(\.key) == ["pinned-old", "recent", "older"])
}
@Test func `preview selection keeps the open chat visible past the cap`() {
let entries = [
self.entry("a", activity: 40),
self.entry("b", activity: 30),
self.entry("c", activity: 20),
self.entry("current", activity: 10),
]
let selection = CommandSessionGrouping.previewSelection(entries, currentKey: "current")
#expect(selection.map(\.key) == ["current", "a", "b"])
// Natural order wins when the current session already fits the cap.
let natural = CommandSessionGrouping.previewSelection(entries, currentKey: "a")
#expect(natural.map(\.key) == ["a", "b", "c"])
// Unknown or empty keys fall back to the plain capped ordering.
let fallback = CommandSessionGrouping.previewSelection(entries, currentKey: "")
#expect(fallback.map(\.key) == ["a", "b", "c"])
}
private func entry(
_ key: String,
category: String? = nil,
pinned: Bool = false,
activity: Double) -> OpenClawChatSessionEntry
{
OpenClawChatSessionEntry(
key: key,
kind: nil,
displayName: nil,
surface: nil,
subject: nil,
room: nil,
space: nil,
updatedAt: nil,
sessionId: nil,
systemSent: nil,
abortedLastRun: nil,
thinkingLevel: nil,
verboseLevel: nil,
inputTokens: nil,
outputTokens: nil,
totalTokens: nil,
modelProvider: nil,
model: nil,
contextTokens: nil,
category: category,
pinned: pinned,
lastActivityAt: activity)
}
}
struct SessionGroupStoreTests {
@Test func `normalizes trims dedupes and drops blanks`() {
#expect(SessionGroupStore.normalized([" Ops ", "Ops", "", " ", "Dev"]) == ["Ops", "Dev"])
}
@Test func `renaming replaces a stored name in place`() {
#expect(SessionGroupStore.renaming(["Dev", "Ops"], from: "Dev", to: "Core") == ["Core", "Ops"])
// Renaming onto an existing name collapses the duplicate.
#expect(SessionGroupStore.renaming(["Dev", "Ops"], from: "Dev", to: "Ops") == ["Ops"])
}
@Test func `renaming a live-only group appends the new name`() {
#expect(SessionGroupStore.renaming(["Ops"], from: "Dev", to: "Core") == ["Ops", "Core"])
}
@Test func `removing and adding keep the list unique`() {
#expect(SessionGroupStore.removing(["Dev", "Ops"], "Dev") == ["Ops"])
#expect(SessionGroupStore.adding(["Ops"], "Ops") == ["Ops"])
#expect(SessionGroupStore.adding(["Ops"], " Dev ") == ["Ops", "Dev"])
}
@Test func `load and save round-trip through user defaults`() {
withUserDefaults([SessionGroupStore.defaultsKey: nil]) {
#expect(SessionGroupStore.load() == [])
SessionGroupStore.save([" Dev ", "Dev", "Ops"])
#expect(SessionGroupStore.load() == ["Dev", "Ops"])
SessionGroupStore.remember("Core")
#expect(SessionGroupStore.load() == ["Dev", "Ops", "Core"])
}
}
}