Coverage Summary for Class: CategoryTree (com.georgeci.moneysurfer.domain.model)

Class Class, % Method, % Branch, % Line, % Instruction, %
CategoryTree 100% (1/1) 100% (5/5) 85.2% (46/54) 100% (48/48) 93.1% (377/405)


 package com.georgeci.moneysurfer.domain.model
 
 import com.georgeci.moneysurfer.domain.primitives.CategoryId
 import com.georgeci.moneysurfer.domain.primitives.CategoryType
 
 /** A category placed in the tree, carrying how deep under a root it sits. */
 data class CategoryNode(
     val category: Category,
     val depth: Int,
 )
 
 /**
  * Shapes a flat category list into the parent/child structure both the manage screen and the
  * parent picker need. Kept in the domain because the rule it encodes — a category may not
  * become its own ancestor — is a data-integrity rule, not a rendering detail.
  */
 object CategoryTree {
 
     /**
      * Deepest indent the UI draws. [eligibleParents] refuses to create anything deeper, but
      * [flatten] still *renders* deeper rows — it just stops indenting them.
      */
     const val MAX_DEPTH: Int = 1
 
     /**
      * Flattens to display order: every category, depth-first, each one immediately followed by
      * its subtree.
      *
      * Every input category comes out exactly once. That is the whole contract, and it is worth
      * stating because the shapes this has to survive are not the shapes the UI can create:
      *
      * - A child whose parent is not in [categories] is treated as a root — sync legitimately
      *   delivers a child before its parent.
      * - A subtree deeper than [MAX_DEPTH] is rendered in full, with the indent clamped. The UI
      *   will not build one, but another client's write can.
      * - A category caught in a parent cycle is reachable from no root at all. It is emitted at
      *   the top level once the real roots are exhausted.
      *
      * In every one of those cases the alternative is a category that silently disappears from
      * the manage screen, which is strictly worse than one drawn at the wrong indent.
      */
     fun flatten(categories: List<Category>): List<CategoryNode> {
         val present = categories.mapTo(mutableSetOf()) { it.id }
         val childrenByParent = categories
             .filter { it.parentId != null && it.parentId in present }
             .groupBy { requireNotNull(it.parentId) }
         val (roots, nested) = categories.partition { it.parentId == null || it.parentId !in present }
 
         val emitted = mutableSetOf<CategoryId>()
         val result = mutableListOf<CategoryNode>()
         val stack = ArrayDeque<CategoryNode>()
 
         // Real roots first; the nested pass then picks up only what no root reached, i.e. the
         // members of a parent cycle. Everything else is already emitted and skipped.
         (roots + nested).forEach { seed ->
             if (seed.id in emitted) return@forEach
             stack.addLast(CategoryNode(seed, depth = 0))
             while (stack.isNotEmpty()) {
                 val node = stack.removeLast()
                 if (!emitted.add(node.category.id)) continue
                 result += node
                 val childDepth = (node.depth + 1).coerceAtMost(MAX_DEPTH)
                 childrenByParent[node.category.id].orEmpty().asReversed().forEach { child ->
                     stack.addLast(CategoryNode(child, childDepth))
                 }
             }
         }
         return result
     }
 
     /** Direct children of [parentId], in the order [categories] arrived. */
     fun childrenOf(categories: List<Category>, parentId: CategoryId): List<Category> =
         categories.filter { it.parentId == parentId }
 
     /**
      * Every category reachable downwards from [rootId].
      *
      * [rootId] itself is in the result only when it is reachable from itself — i.e. it sits in a
      * parent cycle, which sync can deliver even though the UI refuses to create one. That is the
      * useful answer rather than an edge case to paper over: the one caller, [eligibleParents],
      * uses this to decide what a category may *not* be parented to, and a category inside a
      * cycle must not be offered as its own parent either.
      *
      * The walk is iterative and keeps a visited set, so a cycle terminates instead of hanging.
      */
     fun descendantsOf(categories: List<Category>, rootId: CategoryId): Set<CategoryId> {
         val childrenByParent = categories
             .filter { it.parentId != null }
             .groupBy { requireNotNull(it.parentId) }
         val found = mutableSetOf<CategoryId>()
         val queue = ArrayDeque(childrenByParent[rootId].orEmpty().map { it.id })
         while (queue.isNotEmpty()) {
             val next = queue.removeFirst()
             if (!found.add(next)) continue
             queue += childrenByParent[next].orEmpty().map { it.id }
         }
         return found
     }
 
     /**
      * Categories that may be picked as the parent of [categoryId] (null when creating).
      *
      * Excludes: a different type (an expense category under an income parent is meaningless),
      * system categories, the category itself and everything below it (a category cannot be its
      * own ancestor), and any category already nested [MAX_DEPTH] deep.
      */
     fun eligibleParents(
         categories: List<Category>,
         categoryId: CategoryId?,
         type: CategoryType,
     ): List<Category> {
         val excluded = categoryId
             ?.let { descendantsOf(categories, it) + it }
             .orEmpty()
         val byId = categories.associateBy { it.id }
         return categories.filter { candidate ->
             candidate.type == type &&
                 candidate.systemKind == null &&
                 candidate.id !in excluded &&
                 depthOf(candidate, byId) < MAX_DEPTH
         }
     }
 
     private fun depthOf(category: Category, byId: Map<CategoryId, Category>): Int {
         var depth = 0
         var parentId = category.parentId
         val seen = mutableSetOf(category.id)
         while (parentId != null && seen.add(parentId)) {
             depth++
             parentId = byId[parentId]?.parentId
         }
         return depth
     }
 }