chain-studio-flow-editor/test/flow_node_test.dart
flemming-it 7b256b5e35 feat(editor): type-checked connections + card-height fix + widget tests
Three changes:

1. Type-checked port connections. Drag from an output port
   only highlights compatible-type input ports as drop
   targets; incompatible drops are silently refused. Source
   of truth: ModuleSpec for declared types. When either side
   is unknown (no manifest, implicit YAML field) the check
   degrades to 'compatible with anything' so a missing
   manifest never blocks composition.

2. NodeGeometry.heightFor now adds a 2 px allowance for the
   1 px AnimatedContainer border on top + bottom of the
   card. Without this the last port-row's Column would
   overflow by 2 px under tight layout constraints (caught
   by the new widget tests; production canvas clipped it
   silently inside InteractiveViewer).

3. Width parameter on FlowNode now actually drives the
   outer SizedBox. The dynamic _stepWidth value from the
   canvas finally reaches the card border rather than being
   shadowed by NodeGeometry.width. Long labels
   (model_endpoint, source_language) no longer ellipsis-clip.

Tests: 8 new widget + geometry tests covering width growth,
two-column body layout, port-tooltip attachment,
row-alignment between input and output sides, and the
existing kindForStep classifier. All 20 editor tests pass.

Bumps fai_studio_flow_editor to 0.11.0.

Signed-off-by: flemming-it <sf@flemming.it>
2026-06-02 01:29:00 +02:00

112 lines
3.6 KiB
Dart

// Widget tests for FlowNode — the canvas card.
//
// Cover: dynamic width grows with long labels, two-column
// body renders both input + output labels, port tooltips are
// attached when supplied, height accounts for max(inputs,
// outputs).
import 'package:fai_studio_flow_editor/src/model/flow_graph.dart';
import 'package:fai_studio_flow_editor/src/widgets/flow_node.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
Widget pump(Widget child) =>
MaterialApp(home: Scaffold(body: Center(child: child)));
group('NodeGeometry', () {
test('widthFor grows beyond minimum when labels are long', () {
// Bare minimum case.
expect(
NodeGeometry.widthFor(maxInputChars: 4, maxOutputChars: 4),
NodeGeometry.width,
);
// Long labels push past the minimum.
final wide = NodeGeometry.widthFor(
maxInputChars: 13, // "system_prompt"
maxOutputChars: 14, // "model_endpoint"
);
expect(wide, greaterThan(NodeGeometry.width));
});
test('heightFor uses the taller of inputs / outputs', () {
// 3 inputs + 5 outputs → 5-row body.
final h35 = NodeGeometry.heightFor(3, 5);
// 5 inputs + 3 outputs → also 5-row body, same height.
final h53 = NodeGeometry.heightFor(5, 3);
expect(h35, h53);
});
test('inputPortY/outputPortY align row-for-row', () {
// Both sides walk the same row geometry from the body's
// top; that's how the labels stay visually aligned with
// their dots.
expect(NodeGeometry.inputPortY(0), NodeGeometry.outputPortY(0));
expect(NodeGeometry.inputPortY(2), NodeGeometry.outputPortY(2));
});
});
group('FlowNode rendering', () {
testWidgets('renders both input and output labels', (tester) async {
await tester.pumpWidget(
pump(
const FlowNode(
id: 'sum',
title: 'sum',
subtitle: 'llm.chat@^0',
inputPortLabels: ['prompt', 'endpoint'],
outputPortLabels: ['response', 'model_endpoint'],
),
),
);
expect(find.text('prompt'), findsOneWidget);
expect(find.text('endpoint'), findsOneWidget);
expect(find.text('response'), findsOneWidget);
expect(find.text('model_endpoint'), findsOneWidget);
});
testWidgets('honors explicit width parameter', (tester) async {
await tester.pumpWidget(
pump(
const FlowNode(
id: 'x',
title: 'x',
inputPortLabels: ['a_very_long_input_label'],
outputPortLabels: ['another_long_output_label'],
width: 320,
),
),
);
final size = tester.getSize(find.byType(FlowNode));
expect(size.width, 320);
});
testWidgets('attaches Tooltip when portTooltips is non-empty', (
tester,
) async {
await tester.pumpWidget(
pump(
const FlowNode(
id: 'sum',
title: 'sum',
inputPortLabels: ['prompt'],
portTooltips: {'prompt': 'The user-facing prompt text.'},
),
),
);
expect(find.byType(Tooltip), findsAtLeastNWidgets(1));
});
});
group('kindForStep', () {
test('classifies system.approval steps as approval kind', () {
final approval = FlowStep(id: 'r', use: 'system.approval@^0');
expect(kindForStep(approval), NodeVisualKind.approval);
});
test('classifies generic capabilities as module kind', () {
final mod = FlowStep(id: 's', use: 'llm.chat@^0');
expect(kindForStep(mod), NodeVisualKind.module);
});
});
}