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:
flemming-it 2026-06-01 00:37:19 +02:00
parent daf6732893
commit dbd9a0004f
5 changed files with 872 additions and 1 deletions

187
test/flow_graph_test.dart Normal file
View 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');
});
});
}