Compare commits
No commits in common. "main" and "v0.25.0" have entirely different histories.
6 changed files with 31 additions and 333 deletions
21
CHANGELOG.md
21
CHANGELOG.md
|
|
@ -1,26 +1,5 @@
|
||||||
# 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,7 +25,6 @@ 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';
|
||||||
|
|
@ -133,20 +132,6 @@ 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,
|
||||||
|
|
@ -162,8 +147,6 @@ class FlowEditorPage extends StatefulWidget {
|
||||||
this.onPickFile,
|
this.onPickFile,
|
||||||
this.toolbarTrailing,
|
this.toolbarTrailing,
|
||||||
this.flowsDir,
|
this.flowsDir,
|
||||||
this.sampleFlowNames,
|
|
||||||
this.onImportSamples,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -210,21 +193,6 @@ 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);
|
||||||
|
|
@ -356,13 +324,11 @@ 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: name,
|
name: f.uri.pathSegments.last.replaceAll(RegExp(r'\.yaml$'), ''),
|
||||||
path: f.path,
|
path: f.path,
|
||||||
sizeBytes: stat.size,
|
sizeBytes: stat.size,
|
||||||
isExample: widget.sampleFlowNames?.contains(name) ?? false,
|
|
||||||
meta: meta,
|
meta: meta,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
@ -647,17 +613,6 @@ 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,
|
||||||
),
|
),
|
||||||
|
|
@ -1316,27 +1271,28 @@ class _FlowFile {
|
||||||
final String path;
|
final String path;
|
||||||
final int sizeBytes;
|
final int sizeBytes;
|
||||||
|
|
||||||
/// True when the HOST reports this flow as a bundled sample
|
/// Scan result for this file — whether it's a bundled example
|
||||||
/// (hub wire flag) — never guessed from the file content.
|
/// and which capabilities its steps require. Computed once at
|
||||||
final bool isExample;
|
/// list-load time (see [_FlowMetaCache]).
|
||||||
|
|
||||||
/// 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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Result of scanning a single flow file: the capability NAMES
|
/// Marker text every bundled sample flow carries in its
|
||||||
/// (without `@version`) its steps reference.
|
/// provenance comment header. A file is an example iff its raw
|
||||||
|
/// 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
|
||||||
|
|
@ -1344,11 +1300,12 @@ 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(requiredCaps: []);
|
static const empty = _FlowMeta(isExample: false, 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
|
||||||
|
|
@ -1386,11 +1343,12 @@ class _FlowMetaCache {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Scan raw flow YAML for the capability ids referenced by
|
/// Scan raw flow YAML for its example marker and the capability
|
||||||
/// `use:` lines. A line scan is used rather than a full YAML
|
/// ids referenced by `use:` lines. A line scan is used rather
|
||||||
/// parse: it's robust against malformed flows (the analyzer
|
/// than a full YAML parse: it's robust against malformed flows
|
||||||
/// reports those separately) and never throws.
|
/// (the analyzer 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')) {
|
||||||
|
|
@ -1410,6 +1368,7 @@ _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),
|
||||||
);
|
);
|
||||||
|
|
@ -1465,14 +1424,10 @@ 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,
|
||||||
|
|
@ -1492,23 +1447,6 @@ 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);
|
||||||
|
|
@ -1604,15 +1542,6 @@ 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),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -1657,66 +1586,11 @@ class _FileListState extends State<_FileList> {
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Grouping: the operator's own flows first; bundled
|
return ListView.builder(
|
||||||
// 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),
|
||||||
children: [
|
itemCount: files.length,
|
||||||
for (final f in own) _row(theme, f),
|
itemBuilder: (_, i) {
|
||||||
if (samples.isNotEmpty) ...[
|
final f = files[i];
|
||||||
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);
|
||||||
|
|
@ -1788,13 +1662,13 @@ class _FileListState extends State<_FileList> {
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (f.isExample || missing.isNotEmpty) ...[
|
if (f.meta.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.isExample)
|
if (f.meta.isExample)
|
||||||
_ExampleBadge(strings: strings),
|
_ExampleBadge(strings: strings),
|
||||||
if (missing.isNotEmpty)
|
if (missing.isNotEmpty)
|
||||||
MissingModulesBadge(
|
MissingModulesBadge(
|
||||||
|
|
@ -1842,7 +1716,10 @@ class _FileListState extends State<_FileList> {
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -294,10 +294,6 @@ 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,17 +12,11 @@ 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
|
||||||
|
|
@ -52,10 +46,6 @@ 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.26.0
|
version: 0.25.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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,144 +0,0 @@
|
||||||
// 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