Folders can now contain other folders, arbitrarily deep — e.g. "Work" containing "Project A"/"Project B". v10 migration adds a self-referencing parentId column; tree ordering, depth, and cycle detection are pure Swift (Folder.orderedTree/isDescendant/ visibleFolderIds), not SQL, so listFolders() stays a simple flat query. - Create nested folders via "New Subfolder…" (context menu, both list views) or by dragging a folder onto another to reparent it. Dragging onto an existing descendant is rejected (cycle guard). - Deleting a folder reparents its children and any conversations filed directly in it up one level to the deleted folder's own parent — conversations are never deleted. This also fixes a real bug: the previous deleteFolder never persisted unfiling to the database, only patched in-memory state, so a conversation whose folder was deleted kept a dangling folderId and silently vanished from view after the next relaunch. - All "Move to Folder" pickers (sidebar, advanced list, per-row context menus, the Save dialog's folder popup) show an indented flat list reflecting the tree. - New DraggedItem enum disambiguates a dragged folder from dragged conversation(s) in the shared string-based drag payload, and unifies both list views on the same bundled-multi-selection format — closes a gap where dragging a multi-selection in the advanced list (⌘L) only moved the one row grabbed, unlike the sidebar. Confirmed working live, including relaunch-survival of the delete/reparent fix.
178 lines
6.5 KiB
Swift
178 lines
6.5 KiB
Swift
//
|
|
// FolderPureLogicTests.swift
|
|
// oAITests
|
|
//
|
|
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
|
|
// Copyright (C) 2026 Rune Olsen
|
|
|
|
import Testing
|
|
import Foundation
|
|
@testable import Confab
|
|
|
|
@Suite("Folder tree pure logic")
|
|
struct FolderOrderedTreeTests {
|
|
|
|
private func makeFolder(_ name: String, parentId: UUID? = nil) -> Folder {
|
|
Folder(name: name, parentId: parentId)
|
|
}
|
|
|
|
@Test("Single root folder with no children")
|
|
func singleRoot() {
|
|
let work = makeFolder("Work")
|
|
let result = Folder.orderedTree(from: [work])
|
|
#expect(result.map { $0.folder.id } == [work.id])
|
|
#expect(result.map(\.depth) == [0])
|
|
}
|
|
|
|
@Test("Multiple top-level roots preserve their relative order")
|
|
func multipleRootsPreserveOrder() {
|
|
let work = makeFolder("Work")
|
|
let personal = makeFolder("Personal")
|
|
let result = Folder.orderedTree(from: [work, personal])
|
|
#expect(result.map { $0.folder.id } == [work.id, personal.id])
|
|
#expect(result.map(\.depth) == [0, 0])
|
|
}
|
|
|
|
@Test("Parent and children interleave depth-first, not grouped by level")
|
|
func parentChildInterleaveDepthFirst() {
|
|
let work = makeFolder("Work")
|
|
let projectA = makeFolder("Project A", parentId: work.id)
|
|
let projectB = makeFolder("Project B", parentId: work.id)
|
|
let personal = makeFolder("Personal")
|
|
let result = Folder.orderedTree(from: [work, projectA, projectB, personal])
|
|
#expect(result.map { $0.folder.id } == [work.id, projectA.id, projectB.id, personal.id])
|
|
#expect(result.map(\.depth) == [0, 1, 1, 0])
|
|
}
|
|
|
|
@Test("Deep nesting (4-5 levels) reports correct depths")
|
|
func deepNestingDepths() {
|
|
let l0 = makeFolder("L0")
|
|
let l1 = makeFolder("L1", parentId: l0.id)
|
|
let l2 = makeFolder("L2", parentId: l1.id)
|
|
let l3 = makeFolder("L3", parentId: l2.id)
|
|
let l4 = makeFolder("L4", parentId: l3.id)
|
|
let result = Folder.orderedTree(from: [l0, l1, l2, l3, l4])
|
|
#expect(result.map(\.depth) == [0, 1, 2, 3, 4])
|
|
}
|
|
|
|
@Test("Empty input produces an empty tree")
|
|
func emptyInput() {
|
|
#expect(Folder.orderedTree(from: []).isEmpty)
|
|
}
|
|
}
|
|
|
|
@Suite("Folder.isDescendant")
|
|
struct FolderIsDescendantTests {
|
|
|
|
private func makeFolder(_ name: String, parentId: UUID? = nil) -> Folder {
|
|
Folder(name: name, parentId: parentId)
|
|
}
|
|
|
|
@Test("A direct child is a descendant of its parent")
|
|
func directChild() {
|
|
let parent = makeFolder("Work")
|
|
let child = makeFolder("Project A", parentId: parent.id)
|
|
#expect(Folder.isDescendant(child.id, of: parent.id, in: [parent, child]))
|
|
}
|
|
|
|
@Test("A grandchild is a descendant of its grandparent")
|
|
func grandchild() {
|
|
let grandparent = makeFolder("Work")
|
|
let parent = makeFolder("Project A", parentId: grandparent.id)
|
|
let child = makeFolder("Sub-task", parentId: parent.id)
|
|
#expect(Folder.isDescendant(child.id, of: grandparent.id, in: [grandparent, parent, child]))
|
|
}
|
|
|
|
@Test("An unrelated folder is not a descendant")
|
|
func unrelatedFolder() {
|
|
let work = makeFolder("Work")
|
|
let personal = makeFolder("Personal")
|
|
#expect(Folder.isDescendant(personal.id, of: work.id, in: [work, personal]) == false)
|
|
}
|
|
|
|
@Test("A folder is considered a descendant of itself (rejects self-drop)")
|
|
func selfReference() {
|
|
let work = makeFolder("Work")
|
|
#expect(Folder.isDescendant(work.id, of: work.id, in: [work]))
|
|
}
|
|
|
|
@Test("A candidate ID absent from the folder list returns false without crashing")
|
|
func candidateAbsent() {
|
|
let work = makeFolder("Work")
|
|
#expect(Folder.isDescendant(UUID(), of: work.id, in: [work]) == false)
|
|
}
|
|
}
|
|
|
|
@Suite("Folder.visibleFolderIds")
|
|
struct FolderVisibleIdsTests {
|
|
|
|
private func makeFolder(_ name: String, parentId: UUID? = nil) -> Folder {
|
|
Folder(name: name, parentId: parentId)
|
|
}
|
|
|
|
@Test("No collapsed folders means everything is visible")
|
|
func noCollapsedAllVisible() {
|
|
let work = makeFolder("Work")
|
|
let projectA = makeFolder("Project A", parentId: work.id)
|
|
let tree = Folder.orderedTree(from: [work, projectA])
|
|
let visible = Folder.visibleFolderIds(tree: tree, collapsed: [])
|
|
#expect(visible == Set([work.id, projectA.id]))
|
|
}
|
|
|
|
@Test("Collapsing a mid-depth folder hides all deeper descendants but keeps its own id visible")
|
|
func collapsingMidDepthHidesDescendants() {
|
|
let work = makeFolder("Work")
|
|
let projectA = makeFolder("Project A", parentId: work.id)
|
|
let subTask = makeFolder("Sub-task", parentId: projectA.id)
|
|
let tree = Folder.orderedTree(from: [work, projectA, subTask])
|
|
let visible = Folder.visibleFolderIds(tree: tree, collapsed: [projectA.id])
|
|
#expect(visible == Set([work.id, projectA.id]))
|
|
#expect(!visible.contains(subTask.id))
|
|
}
|
|
|
|
@Test("Two unrelated collapsed folders hide their own subtrees independently")
|
|
func independentCollapsedSubtrees() {
|
|
let work = makeFolder("Work")
|
|
let projectA = makeFolder("Project A", parentId: work.id)
|
|
let personal = makeFolder("Personal")
|
|
let hobby = makeFolder("Hobby", parentId: personal.id)
|
|
let tree = Folder.orderedTree(from: [work, projectA, personal, hobby])
|
|
let visible = Folder.visibleFolderIds(tree: tree, collapsed: [work.id, personal.id])
|
|
#expect(visible == Set([work.id, personal.id]))
|
|
}
|
|
}
|
|
|
|
@Suite("DraggedItem")
|
|
struct DraggedItemTests {
|
|
|
|
@Test("A single conversation round-trips through encode/decode")
|
|
func singleConversationRoundTrip() {
|
|
let id = UUID()
|
|
let item = DraggedItem.conversations([id])
|
|
#expect(DraggedItem(rawValue: item.rawValue) == item)
|
|
}
|
|
|
|
@Test("Multiple bundled conversations round-trip through encode/decode")
|
|
func multipleConversationsRoundTrip() {
|
|
let ids = [UUID(), UUID(), UUID()]
|
|
let item = DraggedItem.conversations(ids)
|
|
guard case .conversations(let decoded) = DraggedItem(rawValue: item.rawValue) else {
|
|
Issue.record("Expected .conversations case")
|
|
return
|
|
}
|
|
#expect(decoded == ids)
|
|
}
|
|
|
|
@Test("A folder round-trips through encode/decode")
|
|
func folderRoundTrip() {
|
|
let id = UUID()
|
|
let item = DraggedItem.folder(id)
|
|
#expect(DraggedItem(rawValue: item.rawValue) == item)
|
|
}
|
|
|
|
@Test("A garbage string decodes to nil")
|
|
func garbageStringIsNil() {
|
|
#expect(DraggedItem(rawValue: "not-a-valid-payload") == nil)
|
|
}
|
|
}
|