Compare commits
2 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 36a83f9ae4 | |||
| 29294a2971 |
6 changed files with 333 additions and 31 deletions
21
CHANGELOG.md
21
CHANGELOG.md
|
|
@ -1,5 +1,26 @@
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.26.0
|
||||||
|
|
||||||
|
Sample flows follow the hub's wire flag (sealed-areas rework):
|
||||||
|
|
||||||
|
- **`FlowEditorPage.sampleFlowNames`** — the host passes the set of
|
||||||
|
flow names the hub reports as bundled samples
|
||||||
|
(`FlowSummary.sample`). This is now the ONLY source of the
|
||||||
|
"Example" chip; the editor's own content-marker scan is gone (it
|
||||||
|
had silently drifted from the hub's renamed sample header and
|
||||||
|
missed every current sample). Null = no host info = no chips.
|
||||||
|
- **Examples group.** Sample flows collapse under one
|
||||||
|
"Examples (N)" row below the operator's own flows, expanded on
|
||||||
|
demand — or by default when there is nothing else to show.
|
||||||
|
- **`FlowEditorPage.onImportSamples`** — optional empty-list action
|
||||||
|
("Import example flows"); sealed areas start without samples, so
|
||||||
|
hosts wire this as the deliberate pull. The list refreshes itself
|
||||||
|
after the import.
|
||||||
|
- `FaiEmptyState` gained an optional `action` slot.
|
||||||
|
- The editor reloads its file list when the host changes `flowsDir`
|
||||||
|
or delivers the sample set after an async fetch.
|
||||||
|
|
||||||
## 0.25.0
|
## 0.25.0
|
||||||
|
|
||||||
Honest install badge — the flow list and analyzer only offer
|
Honest install badge — the flow list and analyzer only offer
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ library;
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show setEquals;
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_code_editor/flutter_code_editor.dart';
|
import 'package:flutter_code_editor/flutter_code_editor.dart';
|
||||||
|
|
@ -132,6 +133,20 @@ class FlowEditorPage extends StatefulWidget {
|
||||||
/// (hermetic per shared/TESTING.md).
|
/// (hermetic per shared/TESTING.md).
|
||||||
final String? flowsDir;
|
final String? flowsDir;
|
||||||
|
|
||||||
|
/// Names of the flows the HOST reports as bundled samples (the
|
||||||
|
/// hub's `FlowSummary.sample` wire flag). Null = the host has no
|
||||||
|
/// sample information (old hub / standalone use) — then no
|
||||||
|
/// example chips render at all. The hub's sample header is the
|
||||||
|
/// single source of truth; the editor no longer keeps its own
|
||||||
|
/// marker scan (it had already drifted from the hub's header).
|
||||||
|
final Set<String>? sampleFlowNames;
|
||||||
|
|
||||||
|
/// Import the bundled sample flows into the current hub.
|
||||||
|
/// Rendered as the action of the empty list state — sealed
|
||||||
|
/// areas start without examples, this is the deliberate pull.
|
||||||
|
/// Null hides the action.
|
||||||
|
final Future<void> Function()? onImportSamples;
|
||||||
|
|
||||||
const FlowEditorPage({
|
const FlowEditorPage({
|
||||||
super.key,
|
super.key,
|
||||||
this.initialFlowName,
|
this.initialFlowName,
|
||||||
|
|
@ -147,6 +162,8 @@ class FlowEditorPage extends StatefulWidget {
|
||||||
this.onPickFile,
|
this.onPickFile,
|
||||||
this.toolbarTrailing,
|
this.toolbarTrailing,
|
||||||
this.flowsDir,
|
this.flowsDir,
|
||||||
|
this.sampleFlowNames,
|
||||||
|
this.onImportSamples,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -193,6 +210,21 @@ class _FlowEditorPageState extends State<FlowEditorPage>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant FlowEditorPage old) {
|
||||||
|
super.didUpdateWidget(old);
|
||||||
|
// The host may repoint the directory (connection switch) or
|
||||||
|
// deliver the hub's sample set after an async fetch — both
|
||||||
|
// change what the list must show.
|
||||||
|
if (widget.flowsDir != old.flowsDir ||
|
||||||
|
!setEquals(widget.sampleFlowNames, old.sampleFlowNames)) {
|
||||||
|
final fresh = _listFiles();
|
||||||
|
setState(() {
|
||||||
|
_files = fresh;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_controller.codeController.hoverRequest.removeListener(_onHoverChanged);
|
_controller.codeController.hoverRequest.removeListener(_onHoverChanged);
|
||||||
|
|
@ -324,11 +356,13 @@ class _FlowEditorPageState extends State<FlowEditorPage>
|
||||||
// by path + mtime so a refresh that didn't touch a file
|
// by path + mtime so a refresh that didn't touch a file
|
||||||
// doesn't re-read it, and a paint never re-reads at all.
|
// doesn't re-read it, and a paint never re-reads at all.
|
||||||
final meta = await _flowMetaCache.forFile(f, stat);
|
final meta = await _flowMetaCache.forFile(f, stat);
|
||||||
|
final name = f.uri.pathSegments.last.replaceAll(RegExp(r'\.yaml$'), '');
|
||||||
files.add(
|
files.add(
|
||||||
_FlowFile(
|
_FlowFile(
|
||||||
name: f.uri.pathSegments.last.replaceAll(RegExp(r'\.yaml$'), ''),
|
name: name,
|
||||||
path: f.path,
|
path: f.path,
|
||||||
sizeBytes: stat.size,
|
sizeBytes: stat.size,
|
||||||
|
isExample: widget.sampleFlowNames?.contains(name) ?? false,
|
||||||
meta: meta,
|
meta: meta,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -613,6 +647,17 @@ outputs:
|
||||||
filesFuture: _files,
|
filesFuture: _files,
|
||||||
activeName: _controller.activeName,
|
activeName: _controller.activeName,
|
||||||
strings: _l,
|
strings: _l,
|
||||||
|
onImportSamples: widget.onImportSamples == null
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
await widget.onImportSamples!();
|
||||||
|
if (mounted) {
|
||||||
|
final fresh = _listFiles();
|
||||||
|
setState(() {
|
||||||
|
_files = fresh;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
installedNames: _installedNames(
|
installedNames: _installedNames(
|
||||||
widget.availableCapabilities,
|
widget.availableCapabilities,
|
||||||
),
|
),
|
||||||
|
|
@ -1271,28 +1316,27 @@ class _FlowFile {
|
||||||
final String path;
|
final String path;
|
||||||
final int sizeBytes;
|
final int sizeBytes;
|
||||||
|
|
||||||
/// Scan result for this file — whether it's a bundled example
|
/// True when the HOST reports this flow as a bundled sample
|
||||||
/// and which capabilities its steps require. Computed once at
|
/// (hub wire flag) — never guessed from the file content.
|
||||||
/// list-load time (see [_FlowMetaCache]).
|
final bool isExample;
|
||||||
|
|
||||||
|
/// Scan result for this file — which capabilities its steps
|
||||||
|
/// require. Computed once at list-load time (see
|
||||||
|
/// [_FlowMetaCache]).
|
||||||
final _FlowMeta meta;
|
final _FlowMeta meta;
|
||||||
|
|
||||||
const _FlowFile({
|
const _FlowFile({
|
||||||
required this.name,
|
required this.name,
|
||||||
required this.path,
|
required this.path,
|
||||||
required this.sizeBytes,
|
required this.sizeBytes,
|
||||||
|
required this.isExample,
|
||||||
required this.meta,
|
required this.meta,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Marker text every bundled sample flow carries in its
|
/// Result of scanning a single flow file: the capability NAMES
|
||||||
/// provenance comment header. A file is an example iff its raw
|
/// (without `@version`) its steps reference.
|
||||||
/// content contains this exact string.
|
|
||||||
const String _sampleFlowMarker = 'F∆I sample flow';
|
|
||||||
|
|
||||||
/// Result of scanning a single flow file: provenance + the
|
|
||||||
/// capability NAMES (without `@version`) its steps reference.
|
|
||||||
class _FlowMeta {
|
class _FlowMeta {
|
||||||
final bool isExample;
|
|
||||||
final List<String> requiredCaps;
|
final List<String> requiredCaps;
|
||||||
|
|
||||||
/// The file's own normalized `project:` slug; empty when the
|
/// The file's own normalized `project:` slug; empty when the
|
||||||
|
|
@ -1300,12 +1344,11 @@ class _FlowMeta {
|
||||||
/// filter — display semantics only, the file is never rewritten).
|
/// filter — display semantics only, the file is never rewritten).
|
||||||
final String project;
|
final String project;
|
||||||
const _FlowMeta({
|
const _FlowMeta({
|
||||||
required this.isExample,
|
|
||||||
required this.requiredCaps,
|
required this.requiredCaps,
|
||||||
this.project = '',
|
this.project = '',
|
||||||
});
|
});
|
||||||
|
|
||||||
static const empty = _FlowMeta(isExample: false, requiredCaps: []);
|
static const empty = _FlowMeta(requiredCaps: []);
|
||||||
|
|
||||||
/// Capabilities this flow needs that the hub does not provide.
|
/// Capabilities this flow needs that the hub does not provide.
|
||||||
/// [availableNames] is the set of capability NAMES (the part
|
/// [availableNames] is the set of capability NAMES (the part
|
||||||
|
|
@ -1343,12 +1386,11 @@ class _FlowMetaCache {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Scan raw flow YAML for its example marker and the capability
|
/// Scan raw flow YAML for the capability ids referenced by
|
||||||
/// ids referenced by `use:` lines. A line scan is used rather
|
/// `use:` lines. A line scan is used rather than a full YAML
|
||||||
/// than a full YAML parse: it's robust against malformed flows
|
/// parse: it's robust against malformed flows (the analyzer
|
||||||
/// (the analyzer reports those separately) and never throws.
|
/// reports those separately) and never throws.
|
||||||
_FlowMeta _scanFlow(String text) {
|
_FlowMeta _scanFlow(String text) {
|
||||||
final isExample = text.contains(_sampleFlowMarker);
|
|
||||||
final caps = <String>{};
|
final caps = <String>{};
|
||||||
final useRe = RegExp(r'^\s*-?\s*use:\s*(.+?)\s*$');
|
final useRe = RegExp(r'^\s*-?\s*use:\s*(.+?)\s*$');
|
||||||
for (final raw in text.split('\n')) {
|
for (final raw in text.split('\n')) {
|
||||||
|
|
@ -1368,7 +1410,6 @@ _FlowMeta _scanFlow(String text) {
|
||||||
if (name.isNotEmpty) caps.add(name);
|
if (name.isNotEmpty) caps.add(name);
|
||||||
}
|
}
|
||||||
return _FlowMeta(
|
return _FlowMeta(
|
||||||
isExample: isExample,
|
|
||||||
requiredCaps: caps.toList(),
|
requiredCaps: caps.toList(),
|
||||||
project: parseFlowProject(text),
|
project: parseFlowProject(text),
|
||||||
);
|
);
|
||||||
|
|
@ -1424,10 +1465,14 @@ class _FileList extends StatefulWidget {
|
||||||
/// renders, just without the one-click action.
|
/// renders, just without the one-click action.
|
||||||
final Future<void> Function(List<String>)? onInstallMissing;
|
final Future<void> Function(List<String>)? onInstallMissing;
|
||||||
|
|
||||||
|
/// Import the bundled samples (empty-list action); null hides it.
|
||||||
|
final Future<void> Function()? onImportSamples;
|
||||||
|
|
||||||
const _FileList({
|
const _FileList({
|
||||||
required this.filesFuture,
|
required this.filesFuture,
|
||||||
required this.activeName,
|
required this.activeName,
|
||||||
required this.strings,
|
required this.strings,
|
||||||
|
this.onImportSamples,
|
||||||
required this.installedNames,
|
required this.installedNames,
|
||||||
required this.storeNames,
|
required this.storeNames,
|
||||||
required this.activeProject,
|
required this.activeProject,
|
||||||
|
|
@ -1447,6 +1492,23 @@ class _FileListState extends State<_FileList> {
|
||||||
/// finding: no search over the flow list at all).
|
/// finding: no search over the flow list at all).
|
||||||
String _filter = '';
|
String _filter = '';
|
||||||
|
|
||||||
|
/// Whether the examples group is expanded. Null until the
|
||||||
|
/// operator toggles it — the default then follows the content
|
||||||
|
/// (expanded only when there are no own flows).
|
||||||
|
bool? _samplesExpanded;
|
||||||
|
|
||||||
|
/// Guards the empty-state import button against double-taps.
|
||||||
|
bool _importing = false;
|
||||||
|
|
||||||
|
Future<void> _runImport() async {
|
||||||
|
setState(() => _importing = true);
|
||||||
|
try {
|
||||||
|
await widget.onImportSamples!();
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _importing = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
|
@ -1542,6 +1604,15 @@ class _FileListState extends State<_FileList> {
|
||||||
icon: Icons.folder_outlined,
|
icon: Icons.folder_outlined,
|
||||||
title: strings.listEmptyTitle,
|
title: strings.listEmptyTitle,
|
||||||
hint: strings.listEmptyBody,
|
hint: strings.listEmptyBody,
|
||||||
|
// Sealed areas start without examples — offer the
|
||||||
|
// deliberate pull right where the emptiness shows.
|
||||||
|
action: widget.onImportSamples == null
|
||||||
|
? null
|
||||||
|
: OutlinedButton.icon(
|
||||||
|
onPressed: _importing ? null : _runImport,
|
||||||
|
icon: const Icon(Icons.download_outlined, size: 16),
|
||||||
|
label: Text(strings.flowListSamplesImport),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1586,11 +1657,66 @@ class _FileListState extends State<_FileList> {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return ListView.builder(
|
// Grouping: the operator's own flows first; bundled
|
||||||
|
// examples collapsed under one labelled group so samples
|
||||||
|
// are never mistaken for area work (they expand by
|
||||||
|
// default only when there is nothing else to show).
|
||||||
|
final own = files.where((f) => !f.isExample).toList();
|
||||||
|
final samples = files.where((f) => f.isExample).toList();
|
||||||
|
final samplesExpanded = _samplesExpanded ?? own.isEmpty;
|
||||||
|
return ListView(
|
||||||
padding: const EdgeInsets.symmetric(vertical: FaiSpace.xs),
|
padding: const EdgeInsets.symmetric(vertical: FaiSpace.xs),
|
||||||
itemCount: files.length,
|
children: [
|
||||||
itemBuilder: (_, i) {
|
for (final f in own) _row(theme, f),
|
||||||
final f = files[i];
|
if (samples.isNotEmpty) ...[
|
||||||
|
InkWell(
|
||||||
|
onTap: () =>
|
||||||
|
setState(() => _samplesExpanded = !samplesExpanded),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: FaiSpace.md,
|
||||||
|
vertical: FaiSpace.sm,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.auto_awesome_outlined,
|
||||||
|
size: 14,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
const SizedBox(width: FaiSpace.sm),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
strings.flowListSamplesGroup(samples.length),
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(
|
||||||
|
samplesExpanded
|
||||||
|
? Icons.expand_less
|
||||||
|
: Icons.expand_more,
|
||||||
|
size: 16,
|
||||||
|
color: theme.colorScheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (samplesExpanded) for (final f in samples) _row(theme, f),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _row(ThemeData theme, _FlowFile f) {
|
||||||
|
final strings = widget.strings;
|
||||||
|
{
|
||||||
final isActive = f.name == widget.activeName;
|
final isActive = f.name == widget.activeName;
|
||||||
final missing = f.meta.missingCaps(widget.installedNames);
|
final missing = f.meta.missingCaps(widget.installedNames);
|
||||||
final split = splitMissingCaps(missing, widget.storeNames);
|
final split = splitMissingCaps(missing, widget.storeNames);
|
||||||
|
|
@ -1662,13 +1788,13 @@ class _FileListState extends State<_FileList> {
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (f.meta.isExample || missing.isNotEmpty) ...[
|
if (f.isExample || missing.isNotEmpty) ...[
|
||||||
const SizedBox(height: FaiSpace.xs),
|
const SizedBox(height: FaiSpace.xs),
|
||||||
Wrap(
|
Wrap(
|
||||||
spacing: FaiSpace.xs,
|
spacing: FaiSpace.xs,
|
||||||
runSpacing: FaiSpace.xs,
|
runSpacing: FaiSpace.xs,
|
||||||
children: [
|
children: [
|
||||||
if (f.meta.isExample)
|
if (f.isExample)
|
||||||
_ExampleBadge(strings: strings),
|
_ExampleBadge(strings: strings),
|
||||||
if (missing.isNotEmpty)
|
if (missing.isNotEmpty)
|
||||||
MissingModulesBadge(
|
MissingModulesBadge(
|
||||||
|
|
@ -1716,10 +1842,7 @@ class _FileListState extends State<_FileList> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
}
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -294,6 +294,10 @@ class FlowEditorStrings {
|
||||||
'Bundled sample flow.',
|
'Bundled sample flow.',
|
||||||
'Mitgelieferter Beispiel-Flow.',
|
'Mitgelieferter Beispiel-Flow.',
|
||||||
);
|
);
|
||||||
|
String flowListSamplesGroup(int n) =>
|
||||||
|
_t('Examples ($n)', 'Beispiele ($n)');
|
||||||
|
String get flowListSamplesImport =>
|
||||||
|
_t('Import example flows', 'Beispiel-Flows importieren');
|
||||||
String flowListNeedsModules(int n) => _t(
|
String flowListNeedsModules(int n) => _t(
|
||||||
n == 1 ? '1 module missing' : '$n modules missing',
|
n == 1 ? '1 module missing' : '$n modules missing',
|
||||||
n == 1 ? '1 Modul fehlt' : '$n Module fehlen',
|
n == 1 ? '1 Modul fehlt' : '$n Module fehlen',
|
||||||
|
|
|
||||||
|
|
@ -12,11 +12,17 @@ class FaiEmptyState extends StatelessWidget {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String title;
|
final String title;
|
||||||
final String? hint;
|
final String? hint;
|
||||||
|
|
||||||
|
/// Optional action rendered under the hint (e.g. the empty flow
|
||||||
|
/// list's "import example flows" button).
|
||||||
|
final Widget? action;
|
||||||
|
|
||||||
const FaiEmptyState({
|
const FaiEmptyState({
|
||||||
super.key,
|
super.key,
|
||||||
required this.icon,
|
required this.icon,
|
||||||
required this.title,
|
required this.title,
|
||||||
this.hint,
|
this.hint,
|
||||||
|
this.action,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -46,6 +52,10 @@ class FaiEmptyState extends StatelessWidget {
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
if (action != null) ...[
|
||||||
|
const SizedBox(height: FaiSpace.md),
|
||||||
|
action!,
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
name: chain_studio_flow_editor
|
name: chain_studio_flow_editor
|
||||||
description: Swappable inline YAML editor for F∆I Studio flows.
|
description: Swappable inline YAML editor for F∆I Studio flows.
|
||||||
version: 0.25.0
|
version: 0.26.0
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
repository: https://git.flemming.ai/fai/studio-flow-editor
|
repository: https://git.flemming.ai/fai/studio-flow-editor
|
||||||
|
|
||||||
|
|
|
||||||
144
test/flow_list_samples_test.dart
Normal file
144
test/flow_list_samples_test.dart
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue