// Round-trip + edge-detection tests for FlowGraph. The graph // model is the foundation under the WYSIWYG editor — a bug // here corrupts every flow the operator opens, so the parser // gets full coverage of the shipped example shapes. import 'package:fai_studio_flow_editor/src/model/flow_graph.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group('FlowGraph parser', () { test('parses the canonical hello flow', () { const yaml = ''' name: hello inputs: name: text steps: - id: greet use: debug.echo@^0 with: message: "Hello, \$inputs.name" outputs: greeting: \$greet.echoed '''; final graph = FlowGraph.fromYaml(yaml); expect(graph.name, 'hello'); expect(graph.inputs.keys, contains('name')); expect(graph.inputs['name']!.type, 'text'); expect(graph.steps, hasLength(1)); expect(graph.steps.first.id, 'greet'); expect(graph.steps.first.use, 'debug.echo@^0'); expect(graph.outputs['greeting'], '\$greet.echoed'); }); test('detects step-to-step + input-to-step edges', () { const yaml = ''' inputs: doc: bytes steps: - id: extract use: text.extract@^0 with: document: \$inputs.doc - id: sum use: text.summarize@^0 with: text: \$extract.extracted outputs: result: \$sum.summary '''; final graph = FlowGraph.fromYaml(yaml); final edges = graph.edges; expect(edges, hasLength(3)); // inputs.doc -> extract.document expect( edges.any( (e) => e.fromKind == EdgeEndpointKind.inputs && e.fromField == 'doc' && e.toId == 'extract' && e.toField == 'document', ), isTrue, ); // extract.extracted -> sum.text expect( edges.any( (e) => e.fromKind == EdgeEndpointKind.step && e.fromId == 'extract' && e.toId == 'sum' && e.toField == 'text', ), isTrue, ); // sum.summary -> outputs.result expect( edges.any( (e) => e.fromKind == EdgeEndpointKind.step && e.fromId == 'sum' && e.toKind == EdgeEndpointKind.outputs && e.toField == 'result', ), isTrue, ); }); test('flags system.approval steps via isApproval', () { const yaml = ''' steps: - id: review use: system.approval@^0 with: prompt: "Approve?" '''; final graph = FlowGraph.fromYaml(yaml); expect(graph.steps.single.isApproval, isTrue); }); test('captures leading comment block for round-trip', () { const yaml = ''' # Top-of-file comment. # Spans multiple lines. name: x inputs: a: text steps: [] outputs: {} '''; final graph = FlowGraph.fromYaml(yaml); expect(graph.leadingComment, contains('Top-of-file comment.')); final round = graph.toYaml(); expect(round, contains('Top-of-file comment.')); }); test('round-trips a multi-step flow byte-stably for the shape', () { const yaml = ''' name: round-trip inputs: a: text b: bytes steps: - id: one use: text.extract@^0 with: document: \$inputs.b - id: two use: llm.chat@^0 with: prompt: \$one.extracted outputs: result: \$two.response '''; final first = FlowGraph.fromYaml(yaml); final emitted = first.toYaml(); final second = FlowGraph.fromYaml(emitted); // Structural equality — names, IDs, edges expect(second.name, first.name); expect(second.inputs.keys, first.inputs.keys); expect(second.steps.map((s) => s.id), first.steps.map((s) => s.id)); expect(second.steps.map((s) => s.use), first.steps.map((s) => s.use)); expect(second.outputs, first.outputs); // Edge set identical final firstEdges = first.edges .map((e) => '${e.fromId}.${e.fromField}>${e.toId}.${e.toField}') .toSet(); final secondEdges = second.edges .map((e) => '${e.fromId}.${e.fromField}>${e.toId}.${e.toField}') .toSet(); expect(secondEdges, firstEdges); }); test('tolerates broken YAML — tryParse returns null without throwing', () { const bad = ''' inputs: { this is not yaml '''; expect(FlowGraph.tryParse(bad), isNull); }); test('immutable updates produce independent graphs', () { const yaml = ''' steps: - id: a use: debug.echo@^0 - id: b use: debug.echo@^0 '''; final graph = FlowGraph.fromYaml(yaml); final updated = graph.withStepUpdated( 'a', FlowStep(id: 'a', use: 'debug.echo@^1'), ); expect(graph.steps.first.use, 'debug.echo@^0'); expect(updated.steps.first.use, 'debug.echo@^1'); }); test('handles the extract-with-approval shape (multi-step + approval)', () { // Same shape as the shipped example flow — three // steps, one of which is the system.approval gate, // multi-line prompt with embedded \$step.field refs, // outputs that reference multiple steps. If this // round-trips structurally the editor is safe on the // most complex shipped flow. const yaml = ''' name: extract-with-approval inputs: document: bytes llm_endpoint: text llm_model: text steps: - id: extract use: text.extract@^0 with: document: \$inputs.document - id: review use: system.approval@^0 with: prompt: "Approve before sending to the LLM." show: \$extract.extracted - id: summarize use: llm.chat@^0 with: prompt: "Summarise: \$extract.extracted" endpoint: \$inputs.llm_endpoint model: \$inputs.llm_model outputs: extracted: \$extract.extracted approved_by: \$review.decided_by summary: \$summarize.response '''; final graph = FlowGraph.fromYaml(yaml); expect(graph.steps, hasLength(3)); expect(graph.steps[1].isApproval, isTrue); // Edges: 4 input wires + 3 output wires = 7 // - inputs.document -> extract.document // - extract.extracted -> review.show // - extract.extracted -> summarize.prompt // - inputs.llm_endpoint -> summarize.endpoint // - inputs.llm_model -> summarize.model // - extract.extracted -> outputs.extracted // - review.decided_by -> outputs.approved_by // - summarize.response -> outputs.summary // (8 edges total) expect(graph.edges.length, greaterThanOrEqualTo(8)); // Round-trip stability: parse, emit, re-parse — same // structural shape. final round = FlowGraph.fromYaml(graph.toYaml()); expect(round.steps.map((s) => s.id), graph.steps.map((s) => s.id)); expect(round.outputs.keys, graph.outputs.keys); expect(round.edges.length, graph.edges.length); }); }); }