Files
oai-swift/oAITests/FolderPureLogicTests.swift
rune 2668555b98 Add nested (hierarchical) conversation folders
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.
2026-08-02 17:52:10 +02:00

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)
}
}