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>
This commit is contained in:
flemming-it 2026-08-28 00:08:51 +02:00
parent 8da50068f3
commit 29294a2971
6 changed files with 330 additions and 31 deletions

View file

@ -0,0 +1,144 @@
// 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);
});
}