feat(shared): Add topological sort and pipeline column grouping for phases

Kahn's algorithm for topological phase sorting by dependency edges with
createdAt tiebreaker. groupPhasesByDependencyLevel computes pipeline
visualization columns. Handles cycles gracefully.
This commit is contained in:
Lukas May
2026-02-09 22:33:34 +01:00
parent fab7706f5c
commit 47c3162581
3 changed files with 296 additions and 1 deletions

View File

@@ -0,0 +1,134 @@
import { describe, it, expect } from 'vitest';
import { topologicalSortPhases, type PhaseForSort, type DependencyEdge } from '@codewalk-district/shared';
function mkPhase(id: string, createdAt: string | Date): PhaseForSort {
return { id, createdAt };
}
describe('topologicalSortPhases', () => {
it('should return empty array for empty input', () => {
expect(topologicalSortPhases([], [])).toEqual([]);
});
it('should return phases in createdAt order when no edges', () => {
const phases = [
mkPhase('c', '2026-01-03'),
mkPhase('a', '2026-01-01'),
mkPhase('b', '2026-01-02'),
];
const result = topologicalSortPhases(phases, []);
expect(result.map((p) => p.id)).toEqual(['a', 'b', 'c']);
});
it('should sort linear chain correctly', () => {
// A -> B -> C (B depends on A, C depends on B)
const phases = [
mkPhase('a', '2026-01-01'),
mkPhase('b', '2026-01-02'),
mkPhase('c', '2026-01-03'),
];
const edges: DependencyEdge[] = [
{ phaseId: 'b', dependsOnPhaseId: 'a' },
{ phaseId: 'c', dependsOnPhaseId: 'b' },
];
const result = topologicalSortPhases(phases, edges);
expect(result.map((p) => p.id)).toEqual(['a', 'b', 'c']);
});
it('should handle diamond dependency', () => {
// A
// / \
// B C
// \ /
// D
const phases = [
mkPhase('a', '2026-01-01'),
mkPhase('b', '2026-01-02'),
mkPhase('c', '2026-01-03'),
mkPhase('d', '2026-01-04'),
];
const edges: DependencyEdge[] = [
{ phaseId: 'b', dependsOnPhaseId: 'a' },
{ phaseId: 'c', dependsOnPhaseId: 'a' },
{ phaseId: 'd', dependsOnPhaseId: 'b' },
{ phaseId: 'd', dependsOnPhaseId: 'c' },
];
const result = topologicalSortPhases(phases, edges);
// A must come first, D must come last, B before C by createdAt
expect(result[0].id).toBe('a');
expect(result[3].id).toBe('d');
expect(result.map((p) => p.id)).toEqual(['a', 'b', 'c', 'd']);
});
it('should use createdAt as deterministic tiebreaker', () => {
// Three independent phases — should sort by createdAt
const phases = [
mkPhase('z', '2026-01-03'),
mkPhase('y', '2026-01-01'),
mkPhase('x', '2026-01-02'),
];
const result = topologicalSortPhases(phases, []);
expect(result.map((p) => p.id)).toEqual(['y', 'x', 'z']);
});
it('should handle cycle gracefully by appending cycled nodes', () => {
// A -> B -> A (cycle), C is independent
const phases = [
mkPhase('a', '2026-01-01'),
mkPhase('b', '2026-01-02'),
mkPhase('c', '2026-01-03'),
];
const edges: DependencyEdge[] = [
{ phaseId: 'b', dependsOnPhaseId: 'a' },
{ phaseId: 'a', dependsOnPhaseId: 'b' },
];
const result = topologicalSortPhases(phases, edges);
// C has no deps so it comes first, then A and B appended (cycle)
expect(result[0].id).toBe('c');
expect(result.length).toBe(3);
// A and B are appended in createdAt order
expect(result[1].id).toBe('a');
expect(result[2].id).toBe('b');
});
it('should ignore edges referencing non-existent phases', () => {
const phases = [
mkPhase('a', '2026-01-01'),
mkPhase('b', '2026-01-02'),
];
const edges: DependencyEdge[] = [
{ phaseId: 'b', dependsOnPhaseId: 'nonexistent' },
];
const result = topologicalSortPhases(phases, edges);
// Edge is ignored, both treated as independent
expect(result.map((p) => p.id)).toEqual(['a', 'b']);
});
it('should handle single phase with no edges', () => {
const phases = [mkPhase('only', '2026-01-01')];
const result = topologicalSortPhases(phases, []);
expect(result.map((p) => p.id)).toEqual(['only']);
});
it('should work with Date objects', () => {
const phases = [
mkPhase('b', new Date('2026-01-02')),
mkPhase('a', new Date('2026-01-01')),
];
const edges: DependencyEdge[] = [
{ phaseId: 'b', dependsOnPhaseId: 'a' },
];
const result = topologicalSortPhases(phases, edges);
expect(result.map((p) => p.id)).toEqual(['a', 'b']);
});
it('should preserve extra properties on phase objects', () => {
const phases = [
{ id: 'a', createdAt: '2026-01-01', name: 'Alpha', status: 'pending' },
{ id: 'b', createdAt: '2026-01-02', name: 'Beta', status: 'active' },
];
const result = topologicalSortPhases(phases, []);
expect(result[0].name).toBe('Alpha');
expect(result[1].name).toBe('Beta');
});
});