feat(graph/construction): implement TaskGraph construction methods
- fromTasks: bulk import via serialized blob, orphan nodes for dangling refs, DuplicateNodeError for duplicates, edge dedup, null→undefined stripping - fromRecords: strict validation (TaskNotFoundError for dangling refs, DuplicateEdgeError for duplicate edges), per-edge qualityRetention - fromJSON: TypeBox Value.Check validation, InvalidInputError on schema failure, orphan nodes preserved - addTask: throws DuplicateNodeError if ID exists - addDependency: throws TaskNotFoundError/DuplicateEdgeError, deterministic edge keys per ADR-006, default qualityRetention 0.9 - taskInputToNodeAttrs: strips null→undefined for categorical fields, drops non-graph fields (tags, assignee, due, created, modified) - 47 new unit tests (304 total, all passing)
This commit is contained in:
@@ -1,7 +1,21 @@
|
||||
// TaskGraph class construction — fromTasks, fromRecords, fromJSON, incremental building
|
||||
|
||||
import { DirectedGraph } from 'graphology';
|
||||
import type { TaskGraphNodeAttributes, TaskGraphEdgeAttributes, TaskGraphSerialized } from '../schema/index.js';
|
||||
import { Value } from '@alkdev/typebox/value';
|
||||
import type {
|
||||
TaskGraphNodeAttributes,
|
||||
TaskGraphEdgeAttributes,
|
||||
TaskGraphSerialized,
|
||||
TaskInput,
|
||||
DependencyEdge,
|
||||
} from '../schema/index.js';
|
||||
import { TaskGraphSerialized as TaskGraphSerializedSchema } from '../schema/index.js';
|
||||
import {
|
||||
DuplicateNodeError,
|
||||
DuplicateEdgeError,
|
||||
TaskNotFoundError,
|
||||
InvalidInputError,
|
||||
} from '../error/index.js';
|
||||
|
||||
/**
|
||||
* Internal graph type alias for the graphology DirectedGraph with our attribute types.
|
||||
@@ -10,6 +24,35 @@ import type { TaskGraphNodeAttributes, TaskGraphEdgeAttributes, TaskGraphSeriali
|
||||
*/
|
||||
export type TaskGraphInner = DirectedGraph<TaskGraphNodeAttributes, TaskGraphEdgeAttributes>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: strip null → undefined for TaskInput → TaskGraphNodeAttributes
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Transform a TaskInput into TaskGraphNodeAttributes by:
|
||||
* 1. Stripping null values → undefined (absent = "not assessed")
|
||||
* 2. Dropping non-graph fields (tags, assignee, due, created, modified)
|
||||
*
|
||||
* Per graph-model.md, categorical fields are `Type.Optional(Nullable(Enum))` on input
|
||||
* but `Type.Optional(Enum)` on the graph — null and absent both become "not stored."
|
||||
*/
|
||||
function taskInputToNodeAttrs(input: TaskInput): TaskGraphNodeAttributes {
|
||||
const attrs: TaskGraphNodeAttributes = { name: input.name };
|
||||
|
||||
// Only store non-null categorical fields
|
||||
if (input.status != null) attrs.status = input.status;
|
||||
if (input.scope != null) attrs.scope = input.scope;
|
||||
if (input.risk != null) attrs.risk = input.risk;
|
||||
if (input.impact != null) attrs.impact = input.impact;
|
||||
if (input.level != null) attrs.level = input.level;
|
||||
if (input.priority != null) attrs.priority = input.priority;
|
||||
|
||||
// Note: tags, assignee, due, created, modified are NOT stored on graph nodes
|
||||
// They belong to the caller/consumer, not the graph.
|
||||
|
||||
return attrs;
|
||||
}
|
||||
|
||||
/**
|
||||
* TaskGraph wraps a graphology DirectedGraph and provides the foundation
|
||||
* for construction, mutation, and query methods.
|
||||
@@ -80,54 +123,279 @@ export class TaskGraph {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Static construction methods (stubs — implementation in dependent tasks)
|
||||
// Static construction methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Construct a TaskGraph from an array of TaskInput objects.
|
||||
*
|
||||
* Edges are derived from `dependsOn` arrays with default `qualityRetention: 0.9`.
|
||||
* Dangling references in `dependsOn` silently create orphan nodes.
|
||||
* Transforms `TaskInput[]` into node data + edge data, builds a serialized
|
||||
* blob, and calls `graph.import()`. This is faster than N individual
|
||||
* addNode/addEdge calls and avoids the verbose builder API.
|
||||
*
|
||||
* Semantics:
|
||||
* - Each `dependsOn` entry creates an edge with default `qualityRetention: 0.9`.
|
||||
* - `dependsOn` targets not matching any task ID become **orphan nodes**
|
||||
* with default attributes (`{ name: <dep-id> }`).
|
||||
* - Duplicate task IDs throw `DuplicateNodeError`.
|
||||
* - Uses `mergeNode` for idempotent node merging (same ID gets merged attributes).
|
||||
* - Duplicate `dependsOn` entries for the same pair create only one edge
|
||||
* (idempotent via deterministic edge key).
|
||||
* - Cycles are NOT rejected at construction time — call `hasCycles()` or
|
||||
* `validateGraph()` to detect.
|
||||
*
|
||||
* @param tasks - Array of TaskInput objects
|
||||
* @returns A new TaskGraph populated from the task inputs
|
||||
* @throws {DuplicateNodeError} if duplicate task IDs are found
|
||||
*/
|
||||
static fromTasks(tasks: unknown[]): TaskGraph {
|
||||
// Implementation in dependent task: graph/construction-methods
|
||||
void tasks;
|
||||
throw new Error('TaskGraph.fromTasks() not yet implemented');
|
||||
static fromTasks(tasks: TaskInput[]): TaskGraph {
|
||||
const tg = new TaskGraph();
|
||||
|
||||
// Detect duplicate IDs before any graph mutation
|
||||
const seenIds = new Set<string>();
|
||||
for (const task of tasks) {
|
||||
if (seenIds.has(task.id)) {
|
||||
throw new DuplicateNodeError(task.id);
|
||||
}
|
||||
seenIds.add(task.id);
|
||||
}
|
||||
|
||||
// Build node map: id → TaskGraphNodeAttributes (using mergeNode semantics)
|
||||
// If the same ID appears multiple times, it's an error (checked above).
|
||||
// But for nodes created from dependsOn orphans, mergeNode allows idempotent merge.
|
||||
const nodeMap = new Map<string, TaskGraphNodeAttributes>();
|
||||
|
||||
for (const task of tasks) {
|
||||
const attrs = taskInputToNodeAttrs(task);
|
||||
nodeMap.set(task.id, attrs);
|
||||
}
|
||||
|
||||
// Collect edges from dependsOn arrays and track orphan node IDs
|
||||
const edgeSet = new Set<string>(); // for dedup
|
||||
const edgeEntries: Array<{
|
||||
key: string;
|
||||
source: string;
|
||||
target: string;
|
||||
attributes: TaskGraphEdgeAttributes;
|
||||
}> = [];
|
||||
const orphanIds = new Set<string>();
|
||||
|
||||
for (const task of tasks) {
|
||||
for (const dep of task.dependsOn) {
|
||||
const edgeKey = `${dep}->${task.id}`;
|
||||
if (!edgeSet.has(edgeKey)) {
|
||||
edgeSet.add(edgeKey);
|
||||
edgeEntries.push({
|
||||
key: edgeKey,
|
||||
source: dep,
|
||||
target: task.id,
|
||||
attributes: { qualityRetention: 0.9 },
|
||||
});
|
||||
}
|
||||
|
||||
// Track orphan nodes: dependsOn targets not in the tasks array
|
||||
if (!nodeMap.has(dep)) {
|
||||
orphanIds.add(dep);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add orphan nodes with default attributes
|
||||
for (const orphanId of orphanIds) {
|
||||
nodeMap.set(orphanId, { name: orphanId });
|
||||
}
|
||||
|
||||
// Build serialized blob and import in bulk
|
||||
const serialized = {
|
||||
attributes: {} as Record<string, unknown>,
|
||||
options: {
|
||||
type: 'directed' as const,
|
||||
multi: false as const,
|
||||
allowSelfLoops: false as const,
|
||||
},
|
||||
nodes: Array.from(nodeMap.entries()).map(([key, attributes]) => ({
|
||||
key,
|
||||
attributes,
|
||||
})),
|
||||
edges: edgeEntries,
|
||||
};
|
||||
|
||||
tg._graph.import(serialized);
|
||||
return tg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a TaskGraph from explicit task and edge arrays.
|
||||
*
|
||||
* Unlike `fromTasks`, edges are provided explicitly with per-edge `qualityRetention`.
|
||||
* Dangling references in edges throw `TaskNotFoundError`.
|
||||
* This method is strict:
|
||||
* - Edges must reference tasks that exist in the `tasks` array —
|
||||
* throws `TaskNotFoundError` for dangling references.
|
||||
* - Duplicate task IDs throw `DuplicateNodeError`.
|
||||
* - Duplicate edges (same prerequisite→dependent pair) throw `DuplicateEdgeError`.
|
||||
* - Cycles are NOT rejected at construction time.
|
||||
*
|
||||
* @param tasks - Array of TaskInput objects
|
||||
* @param edges - Array of DependencyEdge objects
|
||||
* @returns A new TaskGraph populated from the records
|
||||
* @throws {DuplicateNodeError} if duplicate task IDs are found
|
||||
* @throws {DuplicateEdgeError} if duplicate prerequisite→dependent pairs are found
|
||||
* @throws {TaskNotFoundError} if an edge references a task ID not in the tasks array
|
||||
*/
|
||||
static fromRecords(tasks: unknown[], edges: unknown[]): TaskGraph {
|
||||
// Implementation in dependent task: graph/construction-methods
|
||||
void tasks;
|
||||
void edges;
|
||||
throw new Error('TaskGraph.fromRecords() not yet implemented');
|
||||
static fromRecords(tasks: TaskInput[], edges: DependencyEdge[]): TaskGraph {
|
||||
const tg = new TaskGraph();
|
||||
|
||||
// Detect duplicate IDs
|
||||
const taskIdSet = new Set<string>();
|
||||
for (const task of tasks) {
|
||||
if (taskIdSet.has(task.id)) {
|
||||
throw new DuplicateNodeError(task.id);
|
||||
}
|
||||
taskIdSet.add(task.id);
|
||||
}
|
||||
|
||||
// Build node map
|
||||
const nodeMap = new Map<string, TaskGraphNodeAttributes>();
|
||||
for (const task of tasks) {
|
||||
nodeMap.set(task.id, taskInputToNodeAttrs(task));
|
||||
}
|
||||
|
||||
// Validate edges and detect duplicates / dangling refs
|
||||
const edgeSet = new Set<string>();
|
||||
const edgeEntries: Array<{
|
||||
key: string;
|
||||
source: string;
|
||||
target: string;
|
||||
attributes: TaskGraphEdgeAttributes;
|
||||
}> = [];
|
||||
|
||||
for (const edge of edges) {
|
||||
const { from: prerequisite, to: dependent } = edge;
|
||||
|
||||
// Check both endpoints exist in the tasks array
|
||||
if (!taskIdSet.has(prerequisite)) {
|
||||
throw new TaskNotFoundError(prerequisite);
|
||||
}
|
||||
if (!taskIdSet.has(dependent)) {
|
||||
throw new TaskNotFoundError(dependent);
|
||||
}
|
||||
|
||||
// Check for duplicate edges
|
||||
const edgeKey = `${prerequisite}->${dependent}`;
|
||||
if (edgeSet.has(edgeKey)) {
|
||||
throw new DuplicateEdgeError(prerequisite, dependent);
|
||||
}
|
||||
edgeSet.add(edgeKey);
|
||||
|
||||
edgeEntries.push({
|
||||
key: edgeKey,
|
||||
source: prerequisite,
|
||||
target: dependent,
|
||||
attributes: {
|
||||
qualityRetention: edge.qualityRetention ?? 0.9,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Build serialized blob and import in bulk
|
||||
const serialized = {
|
||||
attributes: {} as Record<string, unknown>,
|
||||
options: {
|
||||
type: 'directed' as const,
|
||||
multi: false as const,
|
||||
allowSelfLoops: false as const,
|
||||
},
|
||||
nodes: Array.from(nodeMap.entries()).map(([key, attributes]) => ({
|
||||
key,
|
||||
attributes,
|
||||
})),
|
||||
edges: edgeEntries,
|
||||
};
|
||||
|
||||
tg._graph.import(serialized);
|
||||
return tg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a TaskGraph from serialized data (graphology native JSON format).
|
||||
*
|
||||
* Validates input against the `TaskGraphSerialized` schema using TypeBox
|
||||
* `Value.Check`. Invalid data throws an `InvalidInputError` derived from
|
||||
* the first TypeBox validation error.
|
||||
*
|
||||
* If a `target` TaskGraph is provided, it is populated in-place and returned.
|
||||
* Otherwise, a new TaskGraph is created and populated.
|
||||
*
|
||||
* Orphan nodes in the JSON are preserved (graphology import doesn't enforce
|
||||
* connectivity).
|
||||
*
|
||||
* @param data - Serialized graph data in graphology native JSON format
|
||||
* @param target - Optional existing TaskGraph to populate (used by constructor)
|
||||
* @returns A TaskGraph populated from the serialized data
|
||||
* @throws {InvalidInputError} if data fails schema validation
|
||||
*/
|
||||
static fromJSON(data: TaskGraphSerialized, target?: TaskGraph): TaskGraph {
|
||||
// Validate input against TaskGraphSerialized schema
|
||||
if (!Value.Check(TaskGraphSerializedSchema, data)) {
|
||||
const errors = Value.Errors(TaskGraphSerializedSchema, data);
|
||||
const firstError = errors.First();
|
||||
if (firstError) {
|
||||
throw InvalidInputError.fromTypeBoxError(firstError);
|
||||
}
|
||||
// Fallback if no specific error found (shouldn't happen, but be safe)
|
||||
throw new InvalidInputError('data', 'Input does not match TaskGraphSerialized schema');
|
||||
}
|
||||
|
||||
const graph = target ?? new TaskGraph();
|
||||
graph._graph.import(data);
|
||||
return graph;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Incremental construction methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Add a task (node) to the graph.
|
||||
*
|
||||
* @param id - Unique task identifier (used as the node key)
|
||||
* @param attributes - Node attributes for the task
|
||||
* @throws {DuplicateNodeError} if a node with the given ID already exists
|
||||
*/
|
||||
addTask(id: string, attributes: TaskGraphNodeAttributes): void {
|
||||
if (this._graph.hasNode(id)) {
|
||||
throw new DuplicateNodeError(id);
|
||||
}
|
||||
this._graph.addNode(id, attributes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a dependency (edge) between two tasks.
|
||||
*
|
||||
* Creates an edge from `prerequisite` to `dependent` using a deterministic
|
||||
* edge key (`${prerequisite}->${dependent}`) per ADR-006.
|
||||
*
|
||||
* @param prerequisite - Source node (must exist in the graph)
|
||||
* @param dependent - Target node (must exist in the graph)
|
||||
* @param qualityRetention - Optional edge quality retention (default: 0.9)
|
||||
* @throws {TaskNotFoundError} if either endpoint doesn't exist
|
||||
* @throws {DuplicateEdgeError} if an edge between the two nodes already exists
|
||||
*/
|
||||
addDependency(prerequisite: string, dependent: string, qualityRetention: number = 0.9): void {
|
||||
// Validate both endpoints exist
|
||||
if (!this._graph.hasNode(prerequisite)) {
|
||||
throw new TaskNotFoundError(prerequisite);
|
||||
}
|
||||
if (!this._graph.hasNode(dependent)) {
|
||||
throw new TaskNotFoundError(dependent);
|
||||
}
|
||||
|
||||
// Check for duplicate edge
|
||||
const edgeKey = this._edgeKey(prerequisite, dependent);
|
||||
if (this._graph.hasEdge(edgeKey)) {
|
||||
throw new DuplicateEdgeError(prerequisite, dependent);
|
||||
}
|
||||
|
||||
this._graph.addEdgeWithKey(edgeKey, prerequisite, dependent, { qualityRetention });
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
id: graph/construction
|
||||
name: Implement TaskGraph construction methods (fromTasks, fromRecords, fromJSON, addTask, addDependency)
|
||||
status: pending
|
||||
status: completed
|
||||
depends_on:
|
||||
- graph/taskgraph-class
|
||||
scope: broad
|
||||
@@ -18,34 +18,34 @@ Per [graph-model.md](../../../docs/architecture/graph-model.md), the preferred i
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `TaskGraph.fromTasks(tasks: TaskInput[]): TaskGraph`:
|
||||
- [x] `TaskGraph.fromTasks(tasks: TaskInput[]): TaskGraph`:
|
||||
- Transforms `TaskInput[]` into node data + edge data, builds serialized blob, calls `graph.import()`
|
||||
- Each `dependsOn` entry creates an edge with default `qualityRetention: 0.9`
|
||||
- `dependsOn` targets not matching any task ID become orphan nodes with default attributes
|
||||
- Duplicate task IDs throw `DuplicateNodeError`
|
||||
- Uses `mergeNode` for idempotent node merging (same ID gets merged attributes)
|
||||
- Duplicate `dependsOn` entries for the same pair create only one edge (idempotent via `addEdgeWithKey`)
|
||||
- [ ] `TaskGraph.fromRecords(tasks: TaskInput[], edges: DependencyEdge[]): TaskGraph`:
|
||||
- [x] `TaskGraph.fromRecords(tasks: TaskInput[], edges: DependencyEdge[]): TaskGraph`:
|
||||
- Edges must reference tasks that exist in the `tasks` array — throws `TaskNotFoundError` for dangling references
|
||||
- Per-edge `qualityRetention` from the `DependencyEdge` objects
|
||||
- Duplicate task IDs throw `DuplicateNodeError`
|
||||
- Duplicate edges (same prerequisite→dependent pair) throw `DuplicateEdgeError`
|
||||
- [ ] `TaskGraph.fromJSON(data: TaskGraphSerialized): TaskGraph`:
|
||||
- [x] `TaskGraph.fromJSON(data: TaskGraphSerialized): TaskGraph`:
|
||||
- Validates input against `TaskGraphSerialized` schema (using TypeBox `Value.Check`)
|
||||
- Uses `graph.import()` on the validated data
|
||||
- Orphan nodes in JSON are preserved
|
||||
- [ ] `addTask(id: string, attributes: TaskGraphNodeAttributes): void`:
|
||||
- [x] `addTask(id: string, attributes: TaskGraphNodeAttributes): void`:
|
||||
- Throws `DuplicateNodeError` if ID already exists
|
||||
- Adds node to internal graphology instance
|
||||
- [ ] `addDependency(prerequisite: string, dependent: string, qualityRetention?: number): void`:
|
||||
- [x] `addDependency(prerequisite: string, dependent: string, qualityRetention?: number): void`:
|
||||
- Throws `TaskNotFoundError` if either endpoint doesn't exist
|
||||
- Throws `DuplicateEdgeError` if edge already exists
|
||||
- Uses `addEdgeWithKey` with deterministic key `${prerequisite}->${dependent}`
|
||||
- Default `qualityRetention: 0.9` if not provided
|
||||
- [ ] `fromTasks`/`fromRecords` strip `null` → `undefined` for categorical fields during `TaskInput` → `TaskGraphNodeAttributes` transformation
|
||||
- [ ] `TaskInput` fields `tags`, `assignee`, `due`, `created`, `modified` are not stored on graph nodes (belong to caller)
|
||||
- [ ] Unit tests for each construction method: happy path, error cases, edge cases (empty arrays, cycles not rejected at construction time)
|
||||
- [ ] All construction methods use deterministic edge keys per ADR-006
|
||||
- [x] `fromTasks`/`fromRecords` strip `null` → `undefined` for categorical fields during `TaskInput` → `TaskGraphNodeAttributes` transformation
|
||||
- [x] `TaskInput` fields `tags`, `assignee`, `due`, `created`, `modified` are not stored on graph nodes (belong to caller)
|
||||
- [x] Unit tests for each construction method: happy path, error cases, edge cases (empty arrays, cycles not rejected at construction time)
|
||||
- [x] All construction methods use deterministic edge keys per ADR-006
|
||||
|
||||
## References
|
||||
|
||||
@@ -55,8 +55,17 @@ Per [graph-model.md](../../../docs/architecture/graph-model.md), the preferred i
|
||||
|
||||
## Notes
|
||||
|
||||
> To be filled by implementation agent
|
||||
Implementation approach:
|
||||
- `fromTasks` and `fromRecords` use the bulk `graph.import()` approach per architecture recommendation (build serialized blob, import in one call).
|
||||
- `fromJSON` adds TypeBox `Value.Check()` validation before importing, throwing `InvalidInputError` for schema violations.
|
||||
- `fromTasks` creates orphan nodes for dangling dependsOn references with default attributes `{ name: <dep-id> }`.
|
||||
- `fromRecords` is strict — any dangling edge reference throws `TaskNotFoundError`.
|
||||
- `addTask` and `addDependency` are thin wrappers with validation on the underlying graphology instance.
|
||||
- The `taskInputToNodeAttrs` helper strips null→undefined for categorical fields and drops non-graph fields (tags, assignee, due, created, modified).
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
Implemented all five construction methods on the TaskGraph class.
|
||||
- Modified: `src/graph/construction.ts` (full implementation of fromTasks, fromRecords, fromJSON with validation, addTask, addDependency; taskInputToNodeAttrs helper)
|
||||
- Modified: `test/graph.test.ts` (added 47 new tests for construction methods, preserved 42 existing tests)
|
||||
- Tests: 304 total (all passing), lint clean
|
||||
@@ -2,6 +2,13 @@ import { describe, it, expect } from 'vitest';
|
||||
import { hasCycle } from 'graphology-dag';
|
||||
import { TaskGraph, type TaskGraphInner } from '../src/graph/index.js';
|
||||
import type { TaskGraphSerialized } from '../src/schema/index.js';
|
||||
import type { TaskInput, DependencyEdge } from '../src/schema/index.js';
|
||||
import {
|
||||
DuplicateNodeError,
|
||||
DuplicateEdgeError,
|
||||
TaskNotFoundError,
|
||||
InvalidInputError,
|
||||
} from '../src/error/index.js';
|
||||
import {
|
||||
createTaskGraph,
|
||||
linearChainTasks,
|
||||
@@ -201,6 +208,42 @@ describe('TaskGraph class', () => {
|
||||
expect(existing.raw.order).toBe(1);
|
||||
expect(existing.raw.hasNode('m')).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves orphan nodes from JSON', () => {
|
||||
const data: TaskGraphSerialized = {
|
||||
attributes: {},
|
||||
options: { type: 'directed', multi: false, allowSelfLoops: false },
|
||||
nodes: [
|
||||
{ key: 'orphan', attributes: { name: 'Orphan Node' } },
|
||||
{ key: 'connected', attributes: { name: 'Connected' } },
|
||||
],
|
||||
edges: [],
|
||||
};
|
||||
const tg = TaskGraph.fromJSON(data);
|
||||
expect(tg.raw.order).toBe(2);
|
||||
expect(tg.raw.hasNode('orphan')).toBe(true);
|
||||
expect(tg.raw.size).toBe(0);
|
||||
});
|
||||
|
||||
it('validates input against TaskGraphSerialized schema', () => {
|
||||
// Missing required 'options' field
|
||||
const invalid = {
|
||||
attributes: {},
|
||||
nodes: [],
|
||||
edges: [],
|
||||
} as unknown as TaskGraphSerialized;
|
||||
expect(() => TaskGraph.fromJSON(invalid)).toThrow(InvalidInputError);
|
||||
});
|
||||
|
||||
it('validates node key type', () => {
|
||||
const invalid = {
|
||||
attributes: {},
|
||||
options: { type: 'directed', multi: false, allowSelfLoops: false },
|
||||
nodes: [{ key: 123, attributes: { name: 'Bad' } }],
|
||||
edges: [],
|
||||
} as unknown as TaskGraphSerialized;
|
||||
expect(() => TaskGraph.fromJSON(invalid)).toThrow(InvalidInputError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('re-export from src/index.ts', () => {
|
||||
@@ -213,6 +256,522 @@ describe('TaskGraph class', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TaskGraph.fromTasks() tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('TaskGraph.fromTasks', () => {
|
||||
it('creates a graph from a simple TaskInput array', () => {
|
||||
const tasks: TaskInput[] = [
|
||||
{ id: 'a', name: 'Task A', dependsOn: [] },
|
||||
{ id: 'b', name: 'Task B', dependsOn: ['a'] },
|
||||
];
|
||||
const tg = TaskGraph.fromTasks(tasks);
|
||||
expect(tg.raw.order).toBe(2);
|
||||
expect(tg.raw.size).toBe(1);
|
||||
expect(tg.raw.hasNode('a')).toBe(true);
|
||||
expect(tg.raw.hasNode('b')).toBe(true);
|
||||
expect(tg.raw.hasEdge('a->b')).toBe(true);
|
||||
});
|
||||
|
||||
it('creates edges with default qualityRetention 0.9', () => {
|
||||
const tasks: TaskInput[] = [
|
||||
{ id: 'a', name: 'A', dependsOn: [] },
|
||||
{ id: 'b', name: 'B', dependsOn: ['a'] },
|
||||
];
|
||||
const tg = TaskGraph.fromTasks(tasks);
|
||||
const attrs = tg.raw.getEdgeAttributes('a->b');
|
||||
expect(attrs.qualityRetention).toBe(0.9);
|
||||
});
|
||||
|
||||
it('silently creates orphan nodes for dangling dependsOn references', () => {
|
||||
const tasks: TaskInput[] = [
|
||||
{ id: 'b', name: 'Task B', dependsOn: ['nonexistent-a'] },
|
||||
];
|
||||
const tg = TaskGraph.fromTasks(tasks);
|
||||
expect(tg.raw.order).toBe(2);
|
||||
expect(tg.raw.hasNode('nonexistent-a')).toBe(true);
|
||||
expect(tg.raw.getNodeAttributes('nonexistent-a').name).toBe('nonexistent-a');
|
||||
expect(tg.raw.hasEdge('nonexistent-a->b')).toBe(true);
|
||||
});
|
||||
|
||||
it('throws DuplicateNodeError for duplicate task IDs', () => {
|
||||
const tasks: TaskInput[] = [
|
||||
{ id: 'dup', name: 'First', dependsOn: [] },
|
||||
{ id: 'dup', name: 'Second', dependsOn: [] },
|
||||
];
|
||||
expect(() => TaskGraph.fromTasks(tasks)).toThrow(DuplicateNodeError);
|
||||
expect(() => TaskGraph.fromTasks(tasks)).toThrow('Duplicate node: dup');
|
||||
});
|
||||
|
||||
it('deduplicates edges when the same dependsOn appears twice', () => {
|
||||
const tasks: TaskInput[] = [
|
||||
{ id: 'a', name: 'A', dependsOn: [] },
|
||||
{ id: 'b', name: 'B', dependsOn: ['a', 'a'] },
|
||||
];
|
||||
const tg = TaskGraph.fromTasks(tasks);
|
||||
expect(tg.raw.size).toBe(1);
|
||||
});
|
||||
|
||||
it('handles empty task array', () => {
|
||||
const tg = TaskGraph.fromTasks([]);
|
||||
expect(tg.raw.order).toBe(0);
|
||||
expect(tg.raw.size).toBe(0);
|
||||
});
|
||||
|
||||
it('handles tasks with no dependencies', () => {
|
||||
const tasks: TaskInput[] = [
|
||||
{ id: 'a', name: 'A', dependsOn: [] },
|
||||
{ id: 'b', name: 'B', dependsOn: [] },
|
||||
{ id: 'c', name: 'C', dependsOn: [] },
|
||||
];
|
||||
const tg = TaskGraph.fromTasks(tasks);
|
||||
expect(tg.raw.order).toBe(3);
|
||||
expect(tg.raw.size).toBe(0);
|
||||
});
|
||||
|
||||
it('strips null categorical fields (null → undefined, not stored on node)', () => {
|
||||
const tasks: TaskInput[] = [
|
||||
{ id: 'a', name: 'A', dependsOn: [], risk: null, scope: null },
|
||||
];
|
||||
const tg = TaskGraph.fromTasks(tasks);
|
||||
const attrs = tg.raw.getNodeAttributes('a');
|
||||
expect(attrs.risk).toBeUndefined();
|
||||
expect(attrs.scope).toBeUndefined();
|
||||
expect(attrs.name).toBe('A');
|
||||
});
|
||||
|
||||
it('preserves non-null categorical fields', () => {
|
||||
const tasks: TaskInput[] = [
|
||||
{ id: 'a', name: 'A', dependsOn: [], risk: 'high', scope: 'broad' },
|
||||
];
|
||||
const tg = TaskGraph.fromTasks(tasks);
|
||||
const attrs = tg.raw.getNodeAttributes('a');
|
||||
expect(attrs.risk).toBe('high');
|
||||
expect(attrs.scope).toBe('broad');
|
||||
});
|
||||
|
||||
it('does not store tags, assignee, due, created, modified on graph nodes', () => {
|
||||
const tasks: TaskInput[] = [
|
||||
{
|
||||
id: 'a',
|
||||
name: 'A',
|
||||
dependsOn: [],
|
||||
tags: ['backend', 'urgent'],
|
||||
assignee: 'alice',
|
||||
due: '2025-01-01',
|
||||
created: '2024-12-01',
|
||||
modified: '2024-12-15',
|
||||
},
|
||||
];
|
||||
const tg = TaskGraph.fromTasks(tasks);
|
||||
const attrs = tg.raw.getNodeAttributes('a');
|
||||
// These fields should NOT exist on node attributes
|
||||
expect((attrs as Record<string, unknown>)['tags']).toBeUndefined();
|
||||
expect((attrs as Record<string, unknown>)['assignee']).toBeUndefined();
|
||||
expect((attrs as Record<string, unknown>)['due']).toBeUndefined();
|
||||
expect((attrs as Record<string, unknown>)['created']).toBeUndefined();
|
||||
expect((attrs as Record<string, unknown>)['modified']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('builds a linear chain graph correctly', () => {
|
||||
const tasks: TaskInput[] = linearChainTasks.map(t => ({ ...t }));
|
||||
const tg = TaskGraph.fromTasks(tasks);
|
||||
expect(tg.raw.order).toBe(4);
|
||||
expect(tg.raw.size).toBe(3);
|
||||
expect(tg.raw.hasEdge('A->B')).toBe(true);
|
||||
expect(tg.raw.hasEdge('B->C')).toBe(true);
|
||||
expect(tg.raw.hasEdge('C->D')).toBe(true);
|
||||
});
|
||||
|
||||
it('builds a diamond graph correctly', () => {
|
||||
const tasks: TaskInput[] = diamondTasks.map(t => ({ ...t }));
|
||||
const tg = TaskGraph.fromTasks(tasks);
|
||||
expect(tg.raw.order).toBe(4);
|
||||
expect(tg.raw.size).toBe(4);
|
||||
});
|
||||
|
||||
it('builds a graph with cycles (not rejected at construction time)', () => {
|
||||
const tasks: TaskInput[] = cyclicTasks.map(t => ({ ...t }));
|
||||
const tg = TaskGraph.fromTasks(tasks);
|
||||
expect(tg.raw.order).toBe(4);
|
||||
expect(hasCycle(tg.raw)).toBe(true);
|
||||
});
|
||||
|
||||
it('uses deterministic edge keys with -> format', () => {
|
||||
const tasks: TaskInput[] = [
|
||||
{ id: 'setup-project', name: 'Setup', dependsOn: [] },
|
||||
{ id: 'implement-feature', name: 'Implement', dependsOn: ['setup-project'] },
|
||||
];
|
||||
const tg = TaskGraph.fromTasks(tasks);
|
||||
expect(tg.raw.hasEdge('setup-project->implement-feature')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TaskGraph.fromRecords() tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('TaskGraph.fromRecords', () => {
|
||||
it('creates a graph from tasks and explicit edges', () => {
|
||||
const tasks: TaskInput[] = [
|
||||
{ id: 'a', name: 'A', dependsOn: [] },
|
||||
{ id: 'b', name: 'B', dependsOn: [] },
|
||||
];
|
||||
const edges: DependencyEdge[] = [
|
||||
{ from: 'a', to: 'b', qualityRetention: 0.85 },
|
||||
];
|
||||
const tg = TaskGraph.fromRecords(tasks, edges);
|
||||
expect(tg.raw.order).toBe(2);
|
||||
expect(tg.raw.size).toBe(1);
|
||||
expect(tg.raw.hasEdge('a->b')).toBe(true);
|
||||
expect(tg.raw.getEdgeAttributes('a->b').qualityRetention).toBe(0.85);
|
||||
});
|
||||
|
||||
it('uses default qualityRetention 0.9 when not specified', () => {
|
||||
const tasks: TaskInput[] = [
|
||||
{ id: 'a', name: 'A', dependsOn: [] },
|
||||
{ id: 'b', name: 'B', dependsOn: [] },
|
||||
];
|
||||
const edges: DependencyEdge[] = [
|
||||
{ from: 'a', to: 'b' },
|
||||
];
|
||||
const tg = TaskGraph.fromRecords(tasks, edges);
|
||||
expect(tg.raw.getEdgeAttributes('a->b').qualityRetention).toBe(0.9);
|
||||
});
|
||||
|
||||
it('throws TaskNotFoundError for dangling prerequisite reference', () => {
|
||||
const tasks: TaskInput[] = [
|
||||
{ id: 'b', name: 'B', dependsOn: [] },
|
||||
];
|
||||
const edges: DependencyEdge[] = [
|
||||
{ from: 'nonexistent', to: 'b' },
|
||||
];
|
||||
expect(() => TaskGraph.fromRecords(tasks, edges)).toThrow(TaskNotFoundError);
|
||||
expect(() => TaskGraph.fromRecords(tasks, edges)).toThrow('Task not found: nonexistent');
|
||||
});
|
||||
|
||||
it('throws TaskNotFoundError for dangling dependent reference', () => {
|
||||
const tasks: TaskInput[] = [
|
||||
{ id: 'a', name: 'A', dependsOn: [] },
|
||||
];
|
||||
const edges: DependencyEdge[] = [
|
||||
{ from: 'a', to: 'nonexistent' },
|
||||
];
|
||||
expect(() => TaskGraph.fromRecords(tasks, edges)).toThrow(TaskNotFoundError);
|
||||
});
|
||||
|
||||
it('throws DuplicateNodeError for duplicate task IDs', () => {
|
||||
const tasks: TaskInput[] = [
|
||||
{ id: 'dup', name: 'First', dependsOn: [] },
|
||||
{ id: 'dup', name: 'Second', dependsOn: [] },
|
||||
];
|
||||
const edges: DependencyEdge[] = [];
|
||||
expect(() => TaskGraph.fromRecords(tasks, edges)).toThrow(DuplicateNodeError);
|
||||
});
|
||||
|
||||
it('throws DuplicateEdgeError for duplicate prerequisite→dependent pairs', () => {
|
||||
const tasks: TaskInput[] = [
|
||||
{ id: 'a', name: 'A', dependsOn: [] },
|
||||
{ id: 'b', name: 'B', dependsOn: [] },
|
||||
];
|
||||
const edges: DependencyEdge[] = [
|
||||
{ from: 'a', to: 'b' },
|
||||
{ from: 'a', to: 'b', qualityRetention: 0.5 },
|
||||
];
|
||||
expect(() => TaskGraph.fromRecords(tasks, edges)).toThrow(DuplicateEdgeError);
|
||||
});
|
||||
|
||||
it('handles empty tasks and edges', () => {
|
||||
const tg = TaskGraph.fromRecords([], []);
|
||||
expect(tg.raw.order).toBe(0);
|
||||
expect(tg.raw.size).toBe(0);
|
||||
});
|
||||
|
||||
it('handles tasks with no edges', () => {
|
||||
const tasks: TaskInput[] = [
|
||||
{ id: 'a', name: 'A', dependsOn: [] },
|
||||
{ id: 'b', name: 'B', dependsOn: [] },
|
||||
];
|
||||
const tg = TaskGraph.fromRecords(tasks, []);
|
||||
expect(tg.raw.order).toBe(2);
|
||||
expect(tg.raw.size).toBe(0);
|
||||
});
|
||||
|
||||
it('strips null categorical fields during TaskInput → attributes transformation', () => {
|
||||
const tasks: TaskInput[] = [
|
||||
{ id: 'a', name: 'A', dependsOn: [], risk: null, scope: 'narrow' },
|
||||
];
|
||||
const edges: DependencyEdge[] = [];
|
||||
const tg = TaskGraph.fromRecords(tasks, edges);
|
||||
const attrs = tg.raw.getNodeAttributes('a');
|
||||
expect(attrs.risk).toBeUndefined();
|
||||
expect(attrs.scope).toBe('narrow');
|
||||
});
|
||||
|
||||
it('does not store tags, assignee, due, created, modified on graph nodes', () => {
|
||||
const tasks: TaskInput[] = [
|
||||
{ id: 'a', name: 'A', dependsOn: [], tags: ['x'], assignee: 'bob', due: '2025-01-01', created: '2024-01-01', modified: '2024-06-01' },
|
||||
];
|
||||
const edges: DependencyEdge[] = [];
|
||||
const tg = TaskGraph.fromRecords(tasks, edges);
|
||||
const attrs = tg.raw.getNodeAttributes('a');
|
||||
expect((attrs as Record<string, unknown>)['tags']).toBeUndefined();
|
||||
expect((attrs as Record<string, unknown>)['assignee']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('uses deterministic edge keys with -> format', () => {
|
||||
const tasks: TaskInput[] = [
|
||||
{ id: 'setup', name: 'Setup', dependsOn: [] },
|
||||
{ id: 'build', name: 'Build', dependsOn: [] },
|
||||
];
|
||||
const edges: DependencyEdge[] = [
|
||||
{ from: 'setup', to: 'build', qualityRetention: 0.95 },
|
||||
];
|
||||
const tg = TaskGraph.fromRecords(tasks, edges);
|
||||
expect(tg.raw.hasEdge('setup->build')).toBe(true);
|
||||
});
|
||||
|
||||
it('supports per-edge qualityRetention values', () => {
|
||||
const tasks: TaskInput[] = [
|
||||
{ id: 'a', name: 'A', dependsOn: [] },
|
||||
{ id: 'b', name: 'B', dependsOn: [] },
|
||||
{ id: 'c', name: 'C', dependsOn: [] },
|
||||
];
|
||||
const edges: DependencyEdge[] = [
|
||||
{ from: 'a', to: 'b', qualityRetention: 0.7 },
|
||||
{ from: 'a', to: 'c', qualityRetention: 0.5 },
|
||||
];
|
||||
const tg = TaskGraph.fromRecords(tasks, edges);
|
||||
expect(tg.raw.getEdgeAttributes('a->b').qualityRetention).toBe(0.7);
|
||||
expect(tg.raw.getEdgeAttributes('a->c').qualityRetention).toBe(0.5);
|
||||
});
|
||||
|
||||
it('uses default qualityRetention 0.9 when per-edge not provided', () => {
|
||||
const tasks: TaskInput[] = [
|
||||
{ id: 'a', name: 'A', dependsOn: [] },
|
||||
{ id: 'b', name: 'B', dependsOn: [] },
|
||||
];
|
||||
const edges: DependencyEdge[] = [
|
||||
{ from: 'a', to: 'b' }, // no qualityRetention
|
||||
];
|
||||
const tg = TaskGraph.fromRecords(tasks, edges);
|
||||
expect(tg.raw.getEdgeAttributes('a->b').qualityRetention).toBe(0.9);
|
||||
});
|
||||
|
||||
it('allows cycles at construction time (not rejected)', () => {
|
||||
const tasks: TaskInput[] = [
|
||||
{ id: 'a', name: 'A', dependsOn: [] },
|
||||
{ id: 'b', name: 'B', dependsOn: [] },
|
||||
{ id: 'c', name: 'C', dependsOn: [] },
|
||||
];
|
||||
const edges: DependencyEdge[] = [
|
||||
{ from: 'a', to: 'b' },
|
||||
{ from: 'b', to: 'c' },
|
||||
{ from: 'c', to: 'a' },
|
||||
];
|
||||
const tg = TaskGraph.fromRecords(tasks, edges);
|
||||
expect(hasCycle(tg.raw)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TaskGraph.addTask() tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('TaskGraph.addTask', () => {
|
||||
it('adds a task to an empty graph', () => {
|
||||
const tg = new TaskGraph();
|
||||
tg.addTask('a', { name: 'Task A' });
|
||||
expect(tg.raw.order).toBe(1);
|
||||
expect(tg.raw.hasNode('a')).toBe(true);
|
||||
expect(tg.raw.getNodeAttributes('a').name).toBe('Task A');
|
||||
});
|
||||
|
||||
it('adds multiple tasks', () => {
|
||||
const tg = new TaskGraph();
|
||||
tg.addTask('a', { name: 'A' });
|
||||
tg.addTask('b', { name: 'B' });
|
||||
expect(tg.raw.order).toBe(2);
|
||||
});
|
||||
|
||||
it('throws DuplicateNodeError if ID already exists', () => {
|
||||
const tg = new TaskGraph();
|
||||
tg.addTask('dup', { name: 'First' });
|
||||
expect(() => tg.addTask('dup', { name: 'Second' })).toThrow(DuplicateNodeError);
|
||||
expect(() => tg.addTask('dup', { name: 'Second' })).toThrow('Duplicate node: dup');
|
||||
});
|
||||
|
||||
it('stores categorical attributes', () => {
|
||||
const tg = new TaskGraph();
|
||||
tg.addTask('a', { name: 'A', risk: 'high', scope: 'broad', impact: 'project', status: 'pending' });
|
||||
const attrs = tg.raw.getNodeAttributes('a');
|
||||
expect(attrs.risk).toBe('high');
|
||||
expect(attrs.scope).toBe('broad');
|
||||
expect(attrs.impact).toBe('project');
|
||||
expect(attrs.status).toBe('pending');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TaskGraph.addDependency() tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('TaskGraph.addDependency', () => {
|
||||
it('adds a dependency edge with default qualityRetention', () => {
|
||||
const tg = new TaskGraph();
|
||||
tg.addTask('a', { name: 'A' });
|
||||
tg.addTask('b', { name: 'B' });
|
||||
tg.addDependency('a', 'b');
|
||||
expect(tg.raw.size).toBe(1);
|
||||
expect(tg.raw.hasEdge('a->b')).toBe(true);
|
||||
expect(tg.raw.getEdgeAttributes('a->b').qualityRetention).toBe(0.9);
|
||||
});
|
||||
|
||||
it('adds a dependency edge with explicit qualityRetention', () => {
|
||||
const tg = new TaskGraph();
|
||||
tg.addTask('a', { name: 'A' });
|
||||
tg.addTask('b', { name: 'B' });
|
||||
tg.addDependency('a', 'b', 0.75);
|
||||
expect(tg.raw.getEdgeAttributes('a->b').qualityRetention).toBe(0.75);
|
||||
});
|
||||
|
||||
it('uses deterministic edge key format ${prerequisite}->${dependent}', () => {
|
||||
const tg = new TaskGraph();
|
||||
tg.addTask('setup', { name: 'Setup' });
|
||||
tg.addTask('build', { name: 'Build' });
|
||||
tg.addDependency('setup', 'build');
|
||||
expect(tg.raw.hasEdge('setup->build')).toBe(true);
|
||||
});
|
||||
|
||||
it('throws TaskNotFoundError if prerequisite does not exist', () => {
|
||||
const tg = new TaskGraph();
|
||||
tg.addTask('b', { name: 'B' });
|
||||
expect(() => tg.addDependency('nonexistent', 'b')).toThrow(TaskNotFoundError);
|
||||
expect(() => tg.addDependency('nonexistent', 'b')).toThrow('Task not found: nonexistent');
|
||||
});
|
||||
|
||||
it('throws TaskNotFoundError if dependent does not exist', () => {
|
||||
const tg = new TaskGraph();
|
||||
tg.addTask('a', { name: 'A' });
|
||||
expect(() => tg.addDependency('a', 'nonexistent')).toThrow(TaskNotFoundError);
|
||||
});
|
||||
|
||||
it('throws DuplicateEdgeError if edge already exists', () => {
|
||||
const tg = new TaskGraph();
|
||||
tg.addTask('a', { name: 'A' });
|
||||
tg.addTask('b', { name: 'B' });
|
||||
tg.addDependency('a', 'b');
|
||||
expect(() => tg.addDependency('a', 'b')).toThrow(DuplicateEdgeError);
|
||||
expect(() => tg.addDependency('a', 'b')).toThrow('Duplicate edge: a → b');
|
||||
});
|
||||
|
||||
it('allows different edges between different node pairs', () => {
|
||||
const tg = new TaskGraph();
|
||||
tg.addTask('a', { name: 'A' });
|
||||
tg.addTask('b', { name: 'B' });
|
||||
tg.addTask('c', { name: 'C' });
|
||||
tg.addDependency('a', 'b', 0.9);
|
||||
tg.addDependency('a', 'c', 0.8);
|
||||
expect(tg.raw.size).toBe(2);
|
||||
});
|
||||
|
||||
it('edge direction is prerequisite → dependent', () => {
|
||||
const tg = new TaskGraph();
|
||||
tg.addTask('a', { name: 'A' });
|
||||
tg.addTask('b', { name: 'B' });
|
||||
tg.addDependency('a', 'b');
|
||||
expect(tg.raw.source('a->b')).toBe('a');
|
||||
expect(tg.raw.target('a->b')).toBe('b');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-method integration tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Construction methods integration', () => {
|
||||
it('fromTasks + addTask + addDependency builds incrementally', () => {
|
||||
const tasks: TaskInput[] = [
|
||||
{ id: 'a', name: 'A', dependsOn: [] },
|
||||
{ id: 'b', name: 'B', dependsOn: ['a'] },
|
||||
];
|
||||
const tg = TaskGraph.fromTasks(tasks);
|
||||
tg.addTask('c', { name: 'C' });
|
||||
tg.addDependency('b', 'c');
|
||||
expect(tg.raw.order).toBe(3);
|
||||
expect(tg.raw.size).toBe(2);
|
||||
expect(tg.raw.hasEdge('a->b')).toBe(true);
|
||||
expect(tg.raw.hasEdge('b->c')).toBe(true);
|
||||
});
|
||||
|
||||
it('fromRecords then addDependency works', () => {
|
||||
const tasks: TaskInput[] = [
|
||||
{ id: 'a', name: 'A', dependsOn: [] },
|
||||
{ id: 'b', name: 'B', dependsOn: [] },
|
||||
];
|
||||
const edges: DependencyEdge[] = [
|
||||
{ from: 'a', to: 'b', qualityRetention: 0.8 },
|
||||
];
|
||||
const tg = TaskGraph.fromRecords(tasks, edges);
|
||||
tg.addTask('c', { name: 'C' });
|
||||
tg.addDependency('b', 'c', 0.95);
|
||||
expect(tg.raw.order).toBe(3);
|
||||
expect(tg.raw.size).toBe(2);
|
||||
expect(tg.raw.getEdgeAttributes('a->b').qualityRetention).toBe(0.8);
|
||||
expect(tg.raw.getEdgeAttributes('b->c').qualityRetention).toBe(0.95);
|
||||
});
|
||||
|
||||
it('fromJSON then addTask + addDependency works', () => {
|
||||
const data: TaskGraphSerialized = {
|
||||
attributes: {},
|
||||
options: { type: 'directed', multi: false, allowSelfLoops: false },
|
||||
nodes: [{ key: 'a', attributes: { name: 'A' } }],
|
||||
edges: [],
|
||||
};
|
||||
const tg = TaskGraph.fromJSON(data);
|
||||
tg.addTask('b', { name: 'B' });
|
||||
tg.addDependency('a', 'b');
|
||||
expect(tg.raw.order).toBe(2);
|
||||
expect(tg.raw.size).toBe(1);
|
||||
});
|
||||
|
||||
it('all construction methods produce deterministic edge keys', () => {
|
||||
// fromTasks
|
||||
const tg1 = TaskGraph.fromTasks([
|
||||
{ id: 'x', name: 'X', dependsOn: [] },
|
||||
{ id: 'y', name: 'Y', dependsOn: ['x'] },
|
||||
]);
|
||||
expect(tg1.raw.hasEdge('x->y')).toBe(true);
|
||||
|
||||
// fromRecords
|
||||
const tg2 = TaskGraph.fromRecords(
|
||||
[
|
||||
{ id: 'x', name: 'X', dependsOn: [] },
|
||||
{ id: 'y', name: 'Y', dependsOn: [] },
|
||||
],
|
||||
[{ from: 'x', to: 'y' }],
|
||||
);
|
||||
expect(tg2.raw.hasEdge('x->y')).toBe(true);
|
||||
|
||||
// fromJSON
|
||||
const tg3 = TaskGraph.fromJSON({
|
||||
attributes: {},
|
||||
options: { type: 'directed', multi: false, allowSelfLoops: false },
|
||||
nodes: [{ key: 'x', attributes: { name: 'X' } }, { key: 'y', attributes: { name: 'Y' } }],
|
||||
edges: [{ key: 'x->y', source: 'x', target: 'y', attributes: {} }],
|
||||
});
|
||||
expect(tg3.raw.hasEdge('x->y')).toBe(true);
|
||||
|
||||
// addDependency
|
||||
const tg4 = new TaskGraph();
|
||||
tg4.addTask('x', { name: 'X' });
|
||||
tg4.addTask('y', { name: 'Y' });
|
||||
tg4.addDependency('x', 'y');
|
||||
expect(tg4.raw.hasEdge('x->y')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Existing test fixtures (preserved)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user