feat(model): FlowGraph + LayoutStore foundation for WYSIWYG editor
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>
This commit is contained in:
parent
daf6732893
commit
dbd9a0004f
5 changed files with 872 additions and 1 deletions
187
test/flow_graph_test.dart
Normal file
187
test/flow_graph_test.dart
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
// 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');
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue