diff --git a/MyPassCore/Sources/MyPassCore/Session/GroupSelection.swift b/MyPassCore/Sources/MyPassCore/Session/GroupSelection.swift new file mode 100644 index 0000000..6b346e4 --- /dev/null +++ b/MyPassCore/Sources/MyPassCore/Session/GroupSelection.swift @@ -0,0 +1,38 @@ +import Foundation + +public enum GroupSelection { + /// `groupId` and all of its descendant subgroup IDs, found by searching `root`'s tree. + /// Returns an empty set if `groupId` isn't found anywhere in `root`. + public static func subtreeIds(of groupId: UUID, in root: Group) -> Set { + guard let found = findGroup(groupId, in: root) else { return [] } + return allIds(in: found) + } + + /// Toggles `groupId` and its full subtree in `current`: if the group is already selected, + /// it and its subtree are removed; otherwise they're added. + public static func toggling(_ groupId: UUID, in root: Group, current: Set) -> Set { + let subtree = subtreeIds(of: groupId, in: root) + guard !subtree.isEmpty else { return current } + if current.contains(groupId) { + return current.subtracting(subtree) + } else { + return current.union(subtree) + } + } + + private static func findGroup(_ id: UUID, in group: Group) -> Group? { + if group.id == id { return group } + for sub in group.subgroups { + if let found = findGroup(id, in: sub) { return found } + } + return nil + } + + private static func allIds(in group: Group) -> Set { + var ids: Set = [group.id] + for sub in group.subgroups { + ids.formUnion(allIds(in: sub)) + } + return ids + } +} diff --git a/MyPassCore/Tests/MyPassCoreTests/GroupSelectionTests.swift b/MyPassCore/Tests/MyPassCoreTests/GroupSelectionTests.swift new file mode 100644 index 0000000..b5c5a84 --- /dev/null +++ b/MyPassCore/Tests/MyPassCoreTests/GroupSelectionTests.swift @@ -0,0 +1,42 @@ +import XCTest +@testable import MyPassCore + +final class GroupSelectionTests: XCTestCase { + private func makeSampleTree() -> (root: Group, work: Group, email: Group, personal: Group) { + let email = Group(name: "Email") + let work = Group(name: "Work", subgroups: [email]) + let personal = Group(name: "Personal") + let root = Group(name: "Root", subgroups: [work, personal]) + return (root, work, email, personal) + } + + func test_subtreeIds_includesGroupAndAllDescendants() { + let (root, work, email, _) = makeSampleTree() + let ids = GroupSelection.subtreeIds(of: work.id, in: root) + XCTAssertEqual(ids, [work.id, email.id]) + } + + func test_subtreeIds_leafGroup_returnsOnlyItself() { + let (root, _, email, _) = makeSampleTree() + let ids = GroupSelection.subtreeIds(of: email.id, in: root) + XCTAssertEqual(ids, [email.id]) + } + + func test_toggling_selectsGroupAndSubtree() { + let (root, work, email, _) = makeSampleTree() + let result = GroupSelection.toggling(work.id, in: root, current: []) + XCTAssertEqual(result, [work.id, email.id]) + } + + func test_toggling_deselectsGroupAndSubtree() { + let (root, work, email, _) = makeSampleTree() + let result = GroupSelection.toggling(work.id, in: root, current: [work.id, email.id]) + XCTAssertEqual(result, []) + } + + func test_toggling_doesNotAffectUnrelatedSelection() { + let (root, work, email, personal) = makeSampleTree() + let result = GroupSelection.toggling(work.id, in: root, current: [personal.id]) + XCTAssertEqual(result, [personal.id, work.id, email.id]) + } +}