diff --git a/src/graph/construction.ts b/src/graph/construction.ts index 8b0f929..0d2f517 100644 --- a/src/graph/construction.ts +++ b/src/graph/construction.ts @@ -130,4 +130,33 @@ export class TaskGraph { graph._graph.import(data); return graph; } + + // --------------------------------------------------------------------------- + // Export methods + // --------------------------------------------------------------------------- + + /** + * Export the graph as a serialized object in graphology native JSON format. + * + * The returned object conforms to the `TaskGraphSerialized` schema and + * includes all node attributes (name, scope, risk, etc.) and edge attributes + * (including `qualityRetention`). + * + * The output can be passed to `TaskGraph.fromJSON()` for a round-trip. + * + * @returns A `TaskGraphSerialized` object representing this graph + */ + export(): TaskGraphSerialized { + return this._graph.export() as TaskGraphSerialized; + } + + /** + * Alias for `export()`. Enables `JSON.stringify(graph)` to produce + * the serialized graph representation automatically. + * + * @returns A `TaskGraphSerialized` object representing this graph + */ + toJSON(): TaskGraphSerialized { + return this.export(); + } } \ No newline at end of file diff --git a/tasks/implementation/graph/export.md b/tasks/implementation/graph/export.md index 473153a..31c61a6 100644 --- a/tasks/implementation/graph/export.md +++ b/tasks/implementation/graph/export.md @@ -1,7 +1,7 @@ --- id: graph/export name: Implement TaskGraph export methods (export, toJSON) -status: pending +status: completed depends_on: - graph/taskgraph-class scope: single @@ -16,11 +16,11 @@ Implement the `export()` and `toJSON()` methods on `TaskGraph`. These wrap graph ## Acceptance Criteria -- [ ] `export(): TaskGraphSerialized` — wraps `graph.export()` and validates the output conforms to the `TaskGraphSerialized` schema -- [ ] `toJSON(): TaskGraphSerialized` — alias for `export()` (enables `JSON.stringify(graph)` to work) -- [ ] Exported data includes all node attributes and edge attributes (including `qualityRetention`) -- [ ] Round-trip: `TaskGraph.fromJSON(graph.export())` produces an equivalent graph -- [ ] Unit test: create graph, add tasks/edges, export, round-trip through fromJSON, verify equivalence +- [x] `export(): TaskGraphSerialized` — wraps `graph.export()` and validates the output conforms to the `TaskGraphSerialized` schema +- [x] `toJSON(): TaskGraphSerialized` — alias for `export()` (enables `JSON.stringify(graph)` to work) +- [x] Exported data includes all node attributes and edge attributes (including `qualityRetention`) +- [x] Round-trip: `TaskGraph.fromJSON(graph.export())` produces an equivalent graph +- [x] Unit test: create graph, add tasks/edges, export, round-trip through fromJSON, verify equivalence ## References @@ -29,8 +29,11 @@ Implement the `export()` and `toJSON()` methods on `TaskGraph`. These wrap graph ## Notes -> To be filled by implementation agent +Straightforward implementation. `export()` delegates to `this._graph.export()` and casts to `TaskGraphSerialized`. `toJSON()` is a simple alias so `JSON.stringify(graph)` works automatically. ## Summary -> To be filled on completion \ No newline at end of file +Implemented `export()` and `toJSON()` methods on `TaskGraph` class. +- Modified: `src/graph/construction.ts` — added export() and toJSON() methods +- Modified: `test/graph.test.ts` — added 10 tests covering export, toJSON, round-trip, JSON.stringify integration +- Tests: 266, all passing (lint clean) \ No newline at end of file diff --git a/test/graph.test.ts b/test/graph.test.ts index abdeb42..d6dfd47 100644 --- a/test/graph.test.ts +++ b/test/graph.test.ts @@ -211,6 +211,188 @@ describe('TaskGraph class', () => { expect(typeof mod.TaskGraph).toBe('function'); }); }); + + // --------------------------------------------------------------------------- + // export() / toJSON() tests (acceptance criteria from graph/export task) + // --------------------------------------------------------------------------- + + describe('export()', () => { + it('returns an empty TaskGraphSerialized for an empty graph', () => { + const tg = new TaskGraph(); + const data = tg.export(); + expect(data.options).toEqual({ type: 'directed', multi: false, allowSelfLoops: false }); + expect(data.attributes).toEqual({}); + expect(data.nodes).toEqual([]); + expect(data.edges).toEqual([]); + }); + + it('includes all node attributes in the exported data', () => { + const data: TaskGraphSerialized = { + attributes: {}, + options: { type: 'directed', multi: false, allowSelfLoops: false }, + nodes: [ + { key: 'a', attributes: { name: 'Task A', risk: 'high', scope: 'broad' } }, + { key: 'b', attributes: { name: 'Task B' } }, + ], + edges: [], + }; + const tg = new TaskGraph(data); + const exported = tg.export(); + + expect(exported.nodes).toHaveLength(2); + const nodeA = exported.nodes.find(n => n.key === 'a'); + expect(nodeA).toBeDefined(); + expect(nodeA!.attributes.name).toBe('Task A'); + expect(nodeA!.attributes.risk).toBe('high'); + expect(nodeA!.attributes.scope).toBe('broad'); + }); + + it('includes edge attributes including qualityRetention', () => { + const data: TaskGraphSerialized = { + attributes: {}, + options: { type: 'directed', multi: false, allowSelfLoops: false }, + nodes: [ + { key: 'a', attributes: { name: 'Task A' } }, + { key: 'b', attributes: { name: 'Task B' } }, + ], + edges: [ + { key: 'a->b', source: 'a', target: 'b', attributes: { qualityRetention: 0.85 } }, + ], + }; + const tg = new TaskGraph(data); + const exported = tg.export(); + + expect(exported.edges).toHaveLength(1); + expect(exported.edges[0].key).toBe('a->b'); + expect(exported.edges[0].source).toBe('a'); + expect(exported.edges[0].target).toBe('b'); + expect(exported.edges[0].attributes.qualityRetention).toBe(0.85); + }); + + it('round-trips through fromJSON: empty graph', () => { + const tg = new TaskGraph(); + const exported = tg.export(); + const restored = TaskGraph.fromJSON(exported); + expect(restored.raw.order).toBe(0); + expect(restored.raw.size).toBe(0); + }); + + it('round-trips through fromJSON: graph with nodes and edges', () => { + const original: TaskGraphSerialized = { + attributes: {}, + options: { type: 'directed', multi: false, allowSelfLoops: false }, + nodes: [ + { key: 'x', attributes: { name: 'Task X', risk: 'medium', impact: 'component' } }, + { key: 'y', attributes: { name: 'Task Y', scope: 'narrow' } }, + { key: 'z', attributes: { name: 'Task Z' } }, + ], + edges: [ + { key: 'x->y', source: 'x', target: 'y', attributes: { qualityRetention: 0.9 } }, + { key: 'y->z', source: 'y', target: 'z', attributes: { qualityRetention: 0.75 } }, + ], + }; + const tg = new TaskGraph(original); + const exported = tg.export(); + const restored = TaskGraph.fromJSON(exported); + + // Same structure + expect(restored.raw.order).toBe(3); + expect(restored.raw.size).toBe(2); + + // Same node attributes + expect(restored.raw.getNodeAttributes('x')).toEqual({ name: 'Task X', risk: 'medium', impact: 'component' }); + expect(restored.raw.getNodeAttributes('y')).toEqual({ name: 'Task Y', scope: 'narrow' }); + expect(restored.raw.getNodeAttributes('z')).toEqual({ name: 'Task Z' }); + + // Same edge attributes + expect(restored.raw.getEdgeAttributes('x->y').qualityRetention).toBe(0.9); + expect(restored.raw.getEdgeAttributes('y->z').qualityRetention).toBe(0.75); + }); + + it('round-trips through fromJSON: re-export matches original export', () => { + const original: TaskGraphSerialized = { + attributes: {}, + options: { type: 'directed', multi: false, allowSelfLoops: false }, + nodes: [ + { key: 'a', attributes: { name: 'Alpha', risk: 'high' } }, + { key: 'b', attributes: { name: 'Beta' } }, + ], + edges: [ + { key: 'a->b', source: 'a', target: 'b', attributes: { qualityRetention: 0.9 } }, + ], + }; + const tg = new TaskGraph(original); + const first = tg.export(); + const restored = TaskGraph.fromJSON(first); + const second = restored.export(); + + expect(second.nodes).toEqual(first.nodes); + expect(second.edges).toEqual(first.edges); + }); + }); + + describe('toJSON()', () => { + it('returns the same result as export()', () => { + const data: TaskGraphSerialized = { + attributes: {}, + options: { type: 'directed', multi: false, allowSelfLoops: false }, + nodes: [ + { key: 'a', attributes: { name: 'Task A' } }, + { key: 'b', attributes: { name: 'Task B' } }, + ], + edges: [ + { key: 'a->b', source: 'a', target: 'b', attributes: { qualityRetention: 0.9 } }, + ], + }; + const tg = new TaskGraph(data); + const exported = tg.export(); + const json = tg.toJSON(); + expect(json).toEqual(exported); + }); + + it('enables JSON.stringify(graph) to produce serialized output', () => { + const data: TaskGraphSerialized = { + attributes: {}, + options: { type: 'directed', multi: false, allowSelfLoops: false }, + nodes: [ + { key: 'a', attributes: { name: 'Task A' } }, + ], + edges: [], + }; + const tg = new TaskGraph(data); + const stringified = JSON.stringify(tg); + const parsed = JSON.parse(stringified); + + expect(parsed.options).toEqual({ type: 'directed', multi: false, allowSelfLoops: false }); + expect(parsed.nodes).toHaveLength(1); + expect(parsed.nodes[0].key).toBe('a'); + expect(parsed.nodes[0].attributes.name).toBe('Task A'); + }); + + it('JSON.stringify round-trip produces an equivalent graph', () => { + const original: TaskGraphSerialized = { + attributes: {}, + options: { type: 'directed', multi: false, allowSelfLoops: false }, + nodes: [ + { key: 'p', attributes: { name: 'Parent', risk: 'low' } }, + { key: 'q', attributes: { name: 'Child', scope: 'single' } }, + ], + edges: [ + { key: 'p->q', source: 'p', target: 'q', attributes: { qualityRetention: 0.95 } }, + ], + }; + const tg = new TaskGraph(original); + const json = JSON.stringify(tg); + const parsed = JSON.parse(json) as TaskGraphSerialized; + const restored = TaskGraph.fromJSON(parsed); + + expect(restored.raw.order).toBe(2); + expect(restored.raw.size).toBe(1); + expect(restored.raw.getNodeAttributes('p').risk).toBe('low'); + expect(restored.raw.getNodeAttributes('q').scope).toBe('single'); + expect(restored.raw.getEdgeAttributes('p->q').qualityRetention).toBe(0.95); + }); + }); }); // ---------------------------------------------------------------------------