From fe31376f67d80e6e79132d112973cba60c9e7b23 Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Fri, 28 Aug 2026 20:01:12 -0400 Subject: [PATCH] Add tree utility and tests --- api/src/tree.utility.test.ts | 35 +++++++++++++++++++++++++++ api/src/tree.utility.ts | 47 ++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 api/src/tree.utility.test.ts create mode 100644 api/src/tree.utility.ts diff --git a/api/src/tree.utility.test.ts b/api/src/tree.utility.test.ts new file mode 100644 index 0000000..1d71ac3 --- /dev/null +++ b/api/src/tree.utility.test.ts @@ -0,0 +1,35 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { buildCategoryTree, CategoryNode } from './tree.utility.ts'; + +describe('Tree Utility - buildCategoryTree()', () => { + it('should transform a flat list of ltree rows into a nested JSON structure', () => { + const flatNodes: CategoryNode[] = [ + { id: 1, name: 'Root', path: 'Top' }, + { id: 3, name: 'Astronomy', path: 'Top.Science.Astronomy' }, + { id: 2, name: 'Science', path: 'Top.Science' }, + { id: 4, name: 'Gardening', path: 'Top.Hobbies.Gardening' }, + { id: 5, name: 'Hobbies', path: 'Top.Hobbies' } + ]; + + const tree = buildCategoryTree(flatNodes); + + // Verify root nodes count + assert.equal(tree.length, 1); + assert.equal(tree[0].name, 'Root'); + + // Verify second level ("Science" and "Hobbies") + const rootChildren = tree[0].children; + assert.equal(rootChildren.length, 2); + + const scienceNode = rootChildren.find(c => c.name === 'Science'); + const hobbiesNode = rootChildren.find(c => c.name === 'Hobbies'); + + assert.ok(scienceNode); + assert.ok(hobbiesNode); + + // Verify third level deeply nested child ("Astronomy") + assert.equal(scienceNode.children.length, 1); + assert.equal(scienceNode.children[0].name, 'Astronomy'); + }); +}); diff --git a/api/src/tree.utility.ts b/api/src/tree.utility.ts new file mode 100644 index 0000000..7e8dd0e --- /dev/null +++ b/api/src/tree.utility.ts @@ -0,0 +1,47 @@ +export interface CategoryNode { + id: number; + name: string; + path: string; +} + +export interface TreeResponseNode { + id: number; + name: string; + path: string; + children: TreeResponseNode[]; +} + +export function buildCategoryTree(nodes: CategoryNode[]): TreeResponseNode[] { + const tree: TreeResponseNode[] = []; + const nodeMap = new Map(); + + // Sort nodes by path depth (nlevel) implicitly by splitting path strings + // This guarantees parents are always processed before their children + const sortedNodes = [...nodes].sort((a, b) => a.path.split('.').length - b.path.split('.').length); + + // Build the map and establish relationships + for (const node of sortedNodes) { + const treeNode: TreeResponseNode = { ...node, children: [] }; + nodeMap.set(node.path, treeNode); + + const pathParts = node.path.split('.'); + + if (pathParts.length === 1) { + // Root level node (e.g., "Top") + tree.push(treeNode); + } else { + // Find parent path by dropping the last element (e.g., "Top.Science" -> "Top") + const parentPath = pathParts.slice(0, -1).join('.'); + const parentNode = nodeMap.get(parentPath); + + if (parentNode) { + parentNode.children.push(treeNode); + } else { + // Fallback: If parent isn't in the dataset, treat it as an orphan root + tree.push(treeNode); + } + } + } + + return tree; +}