Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Copyright 2025-2026 mrveiss
// SPDX-License-Identifier: Apache-2.0
/**
* AutoBot - AI-Powered Automation Platform
* Author: mrveiss
*
* Unit tests for the shared LLC status mappings (#9909).
*/

import { describe, it, expect } from 'vitest'
import {
agentStatusColor,
companyStatusColor,
runStatusToAgentStatus,
runStatusToRunDisplayStatus,
} from '../llcStatus'

describe('agentStatusColor', () => {
it.each([
['active', 'bg-green-500'],
['idle', 'bg-yellow-400'],
['error', 'bg-red-500'],
['paused', 'bg-gray-400'],
['unknown', 'bg-gray-400'],
])('maps %s → %s', (status, expected) => {
expect(agentStatusColor(status)).toBe(expected)
})
})

describe('companyStatusColor', () => {
it.each([
['active', 'bg-green-500'],
['paused', 'bg-yellow-400'],
['inactive', 'bg-gray-400'],
['unknown', 'bg-gray-400'],
])('maps %s → %s', (status, expected) => {
expect(companyStatusColor(status)).toBe(expected)
})

it('differs from agentStatusColor for paused (yellow vs gray)', () => {
expect(companyStatusColor('paused')).toBe('bg-yellow-400')
expect(agentStatusColor('paused')).toBe('bg-gray-400')
})
})

describe('runStatusToAgentStatus', () => {
it.each([
['running', 'active'],
['failed', 'error'],
['timeout', 'error'],
['interrupted', 'error'],
['completed', 'idle'],
[null, 'idle'],
])('maps %s → %s', (raw, expected) => {
expect(runStatusToAgentStatus(raw as string | null)).toBe(expected)
})
})

describe('runStatusToRunDisplayStatus', () => {
it.each([
['completed', 'done'],
['running', 'running'],
['failed', 'failed'],
['timeout', 'failed'],
['anything-else', 'failed'],
])('maps %s → %s', (raw, expected) => {
expect(runStatusToRunDisplayStatus(raw)).toBe(expected)
})
})
119 changes: 119 additions & 0 deletions autobot-frontend/src/composables/llc/__tests__/useLlcTree.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// Copyright 2025-2026 mrveiss
// SPDX-License-Identifier: Apache-2.0
/**
* AutoBot - AI-Powered Automation Platform
* Author: mrveiss
*
* Unit tests for the shared LLC tree helpers (#9909).
*/

import { describe, it, expect } from 'vitest'
import { markExpanded, buildTreeFromParent, mapTree } from '../useLlcTree'

interface TestNode {
id: string
children: TestNode[]
expanded?: boolean
}

interface FlatItem {
id: string
parent_id: string | null
}

const toNode = (item: FlatItem): TestNode => ({ id: item.id, children: [], expanded: false })

describe('markExpanded', () => {
it('expands top-level nodes and collapses all descendants', () => {
const tree: TestNode[] = [
{
id: 'root',
expanded: false,
children: [
{ id: 'child', expanded: true, children: [{ id: 'grandchild', expanded: true, children: [] }] },
],
},
]
markExpanded(tree, true)
expect(tree[0].expanded).toBe(true)
expect(tree[0].children[0].expanded).toBe(false)
expect(tree[0].children[0].children[0].expanded).toBe(false)
})

it('collapses everything when called with false', () => {
const tree: TestNode[] = [
{ id: 'a', expanded: true, children: [{ id: 'b', expanded: true, children: [] }] },
]
markExpanded(tree, false)
expect(tree[0].expanded).toBe(false)
expect(tree[0].children[0].expanded).toBe(false)
})

it('handles an empty forest', () => {
expect(() => markExpanded([], true)).not.toThrow()
})
})

describe('buildTreeFromParent', () => {
it('assembles a forest from flat parent edges', () => {
const flat: FlatItem[] = [
{ id: 'r1', parent_id: null },
{ id: 'c1', parent_id: 'r1' },
{ id: 'c2', parent_id: 'r1' },
{ id: 'g1', parent_id: 'c1' },
{ id: 'r2', parent_id: null },
]
const roots = buildTreeFromParent(flat, 'id', 'parent_id', toNode)
expect(roots.map((r) => r.id)).toEqual(['r1', 'r2'])
expect(roots[0].children.map((c) => c.id)).toEqual(['c1', 'c2'])
expect(roots[0].children[0].children.map((g) => g.id)).toEqual(['g1'])
expect(roots[1].children).toEqual([])
})

it('treats items with an unknown parent as roots', () => {
const flat: FlatItem[] = [{ id: 'a', parent_id: 'missing' }]
const roots = buildTreeFromParent(flat, 'id', 'parent_id', toNode)
expect(roots.map((r) => r.id)).toEqual(['a'])
})

it('treats self-referencing items as roots (no cycle)', () => {
const flat: FlatItem[] = [{ id: 'a', parent_id: 'a' }]
const roots = buildTreeFromParent(flat, 'id', 'parent_id', toNode)
expect(roots.map((r) => r.id)).toEqual(['a'])
expect(roots[0].children).toEqual([])
})

it('returns an empty forest for empty input', () => {
expect(buildTreeFromParent([] as FlatItem[], 'id', 'parent_id', toNode)).toEqual([])
})
})

describe('mapTree', () => {
interface RawNode {
name: string
children?: RawNode[]
}

it('recursively maps a nested raw tree', () => {
const raw: RawNode = {
name: 'root',
children: [{ name: 'child', children: [{ name: 'grandchild' }] }],
}
const mapped = mapTree(raw, (item, children: TestNode[]) => ({
id: item.name.toUpperCase(),
children,
expanded: false,
}))
expect(mapped.id).toBe('ROOT')
expect(mapped.children[0].id).toBe('CHILD')
expect(mapped.children[0].children[0].id).toBe('GRANDCHILD')
})

it('defaults missing children to an empty array', () => {
const mapped = mapTree({ name: 'leaf' } as RawNode, (item, children: TestNode[]) => ({
id: item.name,
children,
}))
expect(mapped.children).toEqual([])
})
})
55 changes: 55 additions & 0 deletions autobot-frontend/src/composables/llc/llcStatus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Copyright 2025-2026 mrveiss
// SPDX-License-Identifier: Apache-2.0
// AutoBot - AI-Powered Automation Platform
// Author: mrveiss
/**
* Shared LLC status types and display mappings (GH#9909).
*
* Consolidates the status unions, status-dot color maps, and raw
* run-status mappings previously duplicated across OrgTreeNode.vue,
* CompanyTreeNode.vue, and CompanyDashboard.vue.
*
* No generated LLC run-status type exists under src/types or src/api, so
* the unions below are the single source of truth for the frontend.
*/

/** Display status for an LLC agent (org-chart node / dashboard agent grid). */
export type AgentDisplayStatus = 'active' | 'idle' | 'error' | 'paused'

/** Display status for a heartbeat-run row. */
export type RunDisplayStatus = 'running' | 'done' | 'failed'

/**
* Agent status → tailwind status-dot color.
* Exact map shared verbatim by OrgTreeNode.vue and CompanyDashboard.vue
* ('paused' and any unknown status fall through to gray).
*/
export function agentStatusColor(status: string): string {
if (status === 'active') return 'bg-green-500'
if (status === 'idle') return 'bg-yellow-400'
if (status === 'error') return 'bg-red-500'
return 'bg-gray-400'
}

/**
* Company status → tailwind status-dot color (CompanyTreeNode.vue).
* NOTE: intentionally different from agentStatusColor — companies have no
* 'idle'/'error' states, 'paused' is yellow, and 'inactive'/unknown is gray.
*/
export function companyStatusColor(status: string): string {
if (status === 'active') return 'bg-green-500'
if (status === 'paused') return 'bg-yellow-400'
return 'bg-gray-400'
}

/** Raw heartbeat-run status → agent display status. */
export function runStatusToAgentStatus(s: string | null): AgentDisplayStatus {
if (s === 'running') return 'active'
if (s === 'failed' || s === 'timeout' || s === 'interrupted') return 'error'
return 'idle'
}

/** Raw heartbeat-run status → run display status ('completed' → 'done'). */
export function runStatusToRunDisplayStatus(s: string): RunDisplayStatus {
return s === 'completed' ? 'done' : s === 'running' ? 'running' : 'failed'
}
65 changes: 65 additions & 0 deletions autobot-frontend/src/composables/llc/useLlcTree.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Copyright 2025-2026 mrveiss
// SPDX-License-Identifier: Apache-2.0
// AutoBot - AI-Powered Automation Platform
// Author: mrveiss
/**
* Shared LLC tree helpers (GH#9909).
*
* Consolidates the recursive expand-marker and tree-assembly logic that was
* duplicated across GoalTree.vue and SubCompanyTree.vue.
*/

/** Minimal node shape required by markExpanded(). */
export interface ExpandableNode {
expanded?: boolean
children?: ExpandableNode[]
}

/**
* Set the expanded flag on the given nodes; all descendants are collapsed.
* Calling with `expanded = true` expands the roots only (matches the
* previous per-view behavior in GoalTree.vue / SubCompanyTree.vue).
*/
export function markExpanded<T extends ExpandableNode>(nodes: T[], expanded: boolean): void {
for (const node of nodes) {
node.expanded = expanded
if (node.children) markExpanded(node.children, false)
}
}

/**
* Assemble a forest from a flat list using id → parent-id edges.
* Items with a falsy, unknown, or self-referencing parent become roots.
*/
export function buildTreeFromParent<TFlat, TNode extends { children: TNode[] }>(
items: TFlat[],
idKey: keyof TFlat,
parentKey: keyof TFlat,
toNode: (item: TFlat) => TNode,
): TNode[] {
const byId = new Map<unknown, TNode>()
for (const item of items) byId.set(item[idKey], toNode(item))
const roots: TNode[] = []
for (const item of items) {
const node = byId.get(item[idKey])!
const parentId = item[parentKey]
const parent = parentId ? byId.get(parentId) : undefined
if (parent && parent !== node) parent.children.push(node)
else roots.push(node)
}
return roots
}

/**
* Recursively map an already-nested raw tree (e.g. the company /tree
* endpoint response) into a display-node tree.
*/
export function mapTree<TRaw extends { children?: TRaw[] }, TNode>(
raw: TRaw,
toNode: (item: TRaw, children: TNode[]) => TNode,
): TNode {
return toNode(
raw,
(raw.children ?? []).map((child) => mapTree(child, toNode)),
)
}
Loading
Loading