First piece of the v0.2.0 rewrite. The graph view, text view,
and run view will all share a single source of truth: the
YAML text. This commit introduces the parsing layer + the
sidecar that keeps layout metadata OUT of the YAML.
FlowGraph (lib/src/model/flow_graph.dart):
- Parses + emits the F-Delta-I flow YAML shape (inputs +
steps + outputs + leading comment block).
- Recognises both inputs shorthand (`name: text`) and the
expanded form (`name: { type: text, default: ..., hint: ... }`).
- Detects edges by scanning step.with + outputs for
$source.field references; understands both the short
$x.y form (canonical) and the long ${{ x.y }} form (legacy).
- Flags system.approval@ steps via FlowStep.isApproval so
the graph can paint them with the human-gate styling.
- Immutable update helpers (withStepUpdated, withStepAdded,
withStepRemoved) used by the property panel + add-step
button.
- tryParse returns null on YAML syntax errors so the text
tab can keep the last valid graph while the operator
types.
LayoutStore + FlowLayout (lib/src/model/layout_store.dart):
- Per-flow JSON sidecar at
~/.fai/data/flows/.layout/<name>.json. Atomic write via
tmp + rename so a crash mid-save leaves the previous
copy intact.
- The YAML stays byte-identical to what `fai run` consumes
— positions never bleed into the flow file.
AutoLayout (lib/src/model/auto_layout.dart):
- Deterministic topological column layout for flows that
have never been opened in the graph view before.
- Steps with no $step refs sit in column 0; steps whose
refs all point at column-0 steps sit in column 1; etc.
- Cycles + dangling refs collapse to column 0 so nothing
is ever invisible.
Tests (test/flow_graph_test.dart): 7 cases cover canonical
parsing, edge detection across both ref forms, approval-step
flagging, leading-comment round-trip, structural round-trip,
parser robustness on broken YAML, immutability invariant.
All pass.
Version 0.1.4 -> 0.2.0 (minor bump — significant new module
graph; public FlowEditorPage constructor API unchanged).
Signed-off-by: flemming-it <sf@flemming.it>
187 lines
4.7 KiB
Dart
187 lines
4.7 KiB
Dart
// 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');
|
|
});
|
|
});
|
|
}
|