chain-studio-flow-editor/test/flow_list_samples_test.dart
flemming-it 29294a2971 feat: sample flows follow the hub's wire flag; examples group + import action
The Example chip used to come from a client-side content-marker
scan that had drifted from the hub's renamed sample header — no
current sample matched it. The host now passes the hub-reported
sample set (FlowSummary.sample) and the editor renders truth only:
no host info, no chips.

Samples collapse under one 'Examples (N)' group below the
operator's own flows (expanded only when nothing else exists), and
an optional onImportSamples action on the empty list is the
deliberate way to pull the bundled examples into a sealed area's
empty hub. Guard: flow_list_samples_test pins chips-from-host-only,
the grouping, and the import round trip.

Signed-off-by: flemming-it <sf@flemming.it>
2026-08-28 00:08:51 +02:00

144 lines
4.5 KiB
Dart

// Widget-level proof of the sample handling contract (persona
// review 2026-08-27 / sealed-areas rework):
//
// * The host's sample set (the hub's FlowSummary.sample wire
// flag) is the ONLY source of the "Example" chip — no host
// info, no chips. The editor's old content-marker scan had
// silently drifted from the hub's header and is gone.
// * Sample flows collapse under one "Examples (N)" group so
// they are never mistaken for the operator's own work; the
// group expands only on demand (or when nothing else exists).
// * An empty list offers "Import example flows" iff the host
// wired the action (sealed areas start empty on purpose).
//
// Hermetic — temp flows dir, never ~/.chain.
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:chain_studio_flow_editor/chain_studio_flow_editor.dart';
Future<void> _pumpEditor(
WidgetTester tester, {
required String flowsDir,
Set<String>? sampleFlowNames,
Future<void> Function()? onImportSamples,
}) async {
await tester.runAsync(() async {
await tester.pumpWidget(
MaterialApp(
home: FlowEditorPage(
flowsDir: flowsDir,
sampleFlowNames: sampleFlowNames,
onImportSamples: onImportSamples,
),
),
);
await Future<void>.delayed(const Duration(milliseconds: 100));
});
await tester.pump();
await tester.pump();
}
void main() {
late Directory tmp;
setUp(() {
tmp = Directory.systemTemp.createTempSync('chain_editor_samples_test');
File('${tmp.path}/my-work.yaml').writeAsStringSync(
'name: my-work\nsteps: []\n',
);
File('${tmp.path}/hello.yaml').writeAsStringSync(
'name: hello\nsteps: []\n',
);
});
tearDown(() {
tmp.deleteSync(recursive: true);
});
testWidgets('host-reported samples collapse under the examples group', (
tester,
) async {
await _pumpEditor(
tester,
flowsDir: tmp.path,
sampleFlowNames: const {'hello'},
);
// Own flow visible, sample hidden behind the collapsed group.
expect(find.text('my-work'), findsOneWidget);
expect(find.text('hello'), findsNothing);
expect(find.text('Examples (1)'), findsOneWidget);
await tester.tap(find.text('Examples (1)'));
await tester.pump();
expect(find.text('hello'), findsOneWidget);
// The revealed sample row carries the chip.
expect(find.text('Example'), findsOneWidget);
});
testWidgets('no host sample info means no chips and no group', (
tester,
) async {
await _pumpEditor(tester, flowsDir: tmp.path, sampleFlowNames: null);
expect(find.text('my-work'), findsOneWidget);
expect(find.text('hello'), findsOneWidget);
expect(find.text('Example'), findsNothing);
expect(find.textContaining('Examples ('), findsNothing);
});
testWidgets('a samples-only list starts expanded', (tester) async {
File('${tmp.path}/my-work.yaml').deleteSync();
await _pumpEditor(
tester,
flowsDir: tmp.path,
sampleFlowNames: const {'hello'},
);
expect(find.text('hello'), findsOneWidget);
expect(find.text('Examples (1)'), findsOneWidget);
});
testWidgets('the empty list offers the import action when wired', (
tester,
) async {
File('${tmp.path}/my-work.yaml').deleteSync();
File('${tmp.path}/hello.yaml').deleteSync();
var imported = 0;
await _pumpEditor(
tester,
flowsDir: tmp.path,
onImportSamples: () async {
imported++;
File('${tmp.path}/hello.yaml').writeAsStringSync(
'name: hello\nsteps: []\n',
);
},
);
final button = find.text('Import example flows');
expect(button, findsOneWidget);
await tester.runAsync(() async {
await tester.tap(button);
// Let the import callback, the directory re-list (real IO)
// and the FutureBuilder's completion callback all run.
await Future<void>.delayed(const Duration(milliseconds: 200));
await tester.pump();
await Future<void>.delayed(const Duration(milliseconds: 100));
});
await tester.pump();
await tester.pump();
expect(imported, 1);
// The imported flow appears without a manual refresh.
expect(find.text('hello'), findsOneWidget);
});
testWidgets('the empty list stays action-free when nothing is wired', (
tester,
) async {
File('${tmp.path}/my-work.yaml').deleteSync();
File('${tmp.path}/hello.yaml').deleteSync();
await _pumpEditor(tester, flowsDir: tmp.path);
expect(find.text('Import example flows'), findsNothing);
});
}