feat: honest install badge — classify store resolvability before the click
Split each flow's missing capabilities into store-resolvable ones (amber chip + Install link, which now installs only those) and a quiet 'not in store' chip whose tooltip explains the three recovery paths in place: local install via chain install --link, adding the providing store, or configuring the MCP/n8n integration. The analyzer's not-in-store message names the same three paths (EN+DE). Previously the list's Install action covered every missing capability and could end in the hub's 'no store entry' error. Also replace a private-looking capability example name in a doc comment and test with a neutral placeholder. (0.25.0) Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
parent
7d6a575cae
commit
ab97e5e834
8 changed files with 340 additions and 105 deletions
21
CHANGELOG.md
21
CHANGELOG.md
|
|
@ -1,5 +1,26 @@
|
|||
# Changelog
|
||||
|
||||
## 0.25.0
|
||||
|
||||
Honest install badge — the flow list and analyzer only offer
|
||||
"Install" for capabilities a configured store can actually resolve:
|
||||
|
||||
- **Store-resolvability split in the flow list.** Missing
|
||||
capabilities are classified BEFORE any click: store-resolvable
|
||||
ones keep the amber chip + Install link (which now installs only
|
||||
those), the rest render a quiet "not in store" chip whose tooltip
|
||||
explains the three recovery paths (local install via
|
||||
`chain install --link`, adding the providing store, configuring
|
||||
the MCP/n8n integration). Previously the Install action covered
|
||||
every missing capability and could end in the hub's
|
||||
"no store entry for '<name>'" error.
|
||||
- New host contract: `_FileList` consumes the host's
|
||||
`storeCapabilities` (already exposed on `FlowEditorPage`) for the
|
||||
split; hosts should pass only capabilities the store can install
|
||||
(installable status, native kind).
|
||||
- **Analyzer not-in-store message** now names all three recovery
|
||||
paths instead of only the local-install hint (EN + DE).
|
||||
|
||||
## 0.24.1
|
||||
|
||||
- No ALL-CAPS "FLOWS" panel header: the page toolbar already names
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import 'tokens.dart';
|
|||
import 'widgets.dart';
|
||||
import 'widgets/capability_picker.dart';
|
||||
import 'widgets/flow_canvas.dart';
|
||||
import 'widgets/missing_modules_badge.dart';
|
||||
import 'widgets/properties_panel.dart';
|
||||
import 'widgets/run_tab.dart';
|
||||
|
||||
|
|
@ -591,6 +592,7 @@ outputs:
|
|||
installedNames: _installedNames(
|
||||
widget.availableCapabilities,
|
||||
),
|
||||
storeNames: _installedNames(widget.storeCapabilities),
|
||||
onOpen: _openFile,
|
||||
onRefresh: _refreshFiles,
|
||||
onStart: _startFile,
|
||||
|
|
@ -1347,6 +1349,12 @@ class _FileList extends StatefulWidget {
|
|||
/// Bare capability NAMES the host reports as installed. Used
|
||||
/// to compute each row's missing-module count.
|
||||
final Set<String> installedNames;
|
||||
|
||||
/// Bare capability NAMES a configured store can install
|
||||
/// (host-filtered to installable entries). Missing caps
|
||||
/// outside this set render the "not in store" state instead
|
||||
/// of an install action that the hub would refuse.
|
||||
final Set<String> storeNames;
|
||||
final void Function(_FlowFile) onOpen;
|
||||
final VoidCallback onRefresh;
|
||||
|
||||
|
|
@ -1365,6 +1373,7 @@ class _FileList extends StatefulWidget {
|
|||
required this.activeName,
|
||||
required this.strings,
|
||||
required this.installedNames,
|
||||
required this.storeNames,
|
||||
required this.onOpen,
|
||||
required this.onRefresh,
|
||||
required this.onStart,
|
||||
|
|
@ -1502,6 +1511,7 @@ class _FileListState extends State<_FileList> {
|
|||
final f = files[i];
|
||||
final isActive = f.name == widget.activeName;
|
||||
final missing = f.meta.missingCaps(widget.installedNames);
|
||||
final split = splitMissingCaps(missing, widget.storeNames);
|
||||
return InkWell(
|
||||
onTap: () => widget.onOpen(f),
|
||||
child: Container(
|
||||
|
|
@ -1579,13 +1589,20 @@ class _FileListState extends State<_FileList> {
|
|||
if (f.meta.isExample)
|
||||
_ExampleBadge(strings: strings),
|
||||
if (missing.isNotEmpty)
|
||||
_MissingModulesBadge(
|
||||
missing: missing,
|
||||
MissingModulesBadge(
|
||||
installable: split.installable,
|
||||
notInStore: split.notInStore,
|
||||
strings: strings,
|
||||
onInstall: widget.onInstallMissing == null
|
||||
// Install only what the store
|
||||
// resolves — the not-in-store
|
||||
// chip explains the rest.
|
||||
onInstall:
|
||||
widget.onInstallMissing == null ||
|
||||
split.installable.isEmpty
|
||||
? null
|
||||
: () =>
|
||||
widget.onInstallMissing!(missing),
|
||||
: () => widget.onInstallMissing!(
|
||||
split.installable,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
|
@ -1665,95 +1682,6 @@ class _ExampleBadge extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
/// "N modules missing" status on flows whose steps reference
|
||||
/// capabilities the hub doesn't provide. Status and action are
|
||||
/// SEPARATE elements (usertest: the combined orange chip made
|
||||
/// the whole list read like an error wall and ellipsized the
|
||||
/// action word):
|
||||
/// - status = quiet neutral chip with a small amber dot — a
|
||||
/// note, not an alarm; warning orange stays reserved for real
|
||||
/// failures.
|
||||
/// - action = its own "Install" link that is never truncated;
|
||||
/// both live in the row's Wrap, so tight widths wrap to a
|
||||
/// second line instead of cutting text mid-word.
|
||||
class _MissingModulesBadge extends StatelessWidget {
|
||||
final List<String> missing;
|
||||
final FlowEditorStrings strings;
|
||||
final VoidCallback? onInstall;
|
||||
const _MissingModulesBadge({
|
||||
required this.missing,
|
||||
required this.strings,
|
||||
required this.onInstall,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
const dot = Color(0xFFEF6C00);
|
||||
final fg = theme.colorScheme.onSurfaceVariant;
|
||||
final chip = Tooltip(
|
||||
message: strings.flowListNeedsModulesTooltip(missing.join('\n')),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: const BoxDecoration(
|
||||
color: dot,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
strings.flowListNeedsModules(missing.length),
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: fg,
|
||||
fontSize: 10,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (onInstall == null) return chip;
|
||||
return Wrap(
|
||||
spacing: FaiSpace.xs,
|
||||
runSpacing: FaiSpace.xs,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
chip,
|
||||
InkWell(
|
||||
onTap: onInstall,
|
||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
child: Text(
|
||||
strings.flowListInstallMissing,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- empty state ---
|
||||
|
||||
class _EmptyState extends StatelessWidget {
|
||||
|
|
|
|||
|
|
@ -259,11 +259,14 @@ class FlowEditorStrings {
|
|||
);
|
||||
String analyzerUnknownCapNotInStore(String cap) => _t(
|
||||
'Unknown capability "$cap". '
|
||||
'Not in the store — install locally with '
|
||||
'`chain install --link <path>` or check the spelling.',
|
||||
'No configured store can install it — install a local module '
|
||||
'(`chain install --link <path>`), add the store that provides '
|
||||
'it, or configure the integration (MCP/n8n) that supplies it.',
|
||||
'Unbekannte Capability "$cap". '
|
||||
'Nicht im Store — lokal mit '
|
||||
'`chain install --link <pfad>` installieren oder Tippfehler prüfen.',
|
||||
'Kein eingerichteter Store kann sie installieren — lokales '
|
||||
'Modul installieren (`chain install --link <pfad>`), den '
|
||||
'passenden Store hinzufügen oder die Anbindung (MCP/n8n) '
|
||||
'einrichten, die sie bereitstellt.',
|
||||
);
|
||||
String analyzerInputKind() => _t('input', 'Eingabe');
|
||||
String analyzerOutputKind() => _t('output', 'Ausgabe');
|
||||
|
|
@ -292,6 +295,24 @@ class FlowEditorStrings {
|
|||
'Klicken, um die fehlenden zu installieren.',
|
||||
);
|
||||
String get flowListInstallMissing => _t('Install', 'Installieren');
|
||||
String flowListNotInStore(int n) => _t(
|
||||
n == 1 ? 'not in store' : '$n not in store',
|
||||
n == 1 ? 'nicht im Store' : '$n nicht im Store',
|
||||
);
|
||||
String flowListNotInStoreTooltip(String caps) => _t(
|
||||
'This flow needs capabilities no configured store can install:\n'
|
||||
'$caps\n'
|
||||
'Ways to get them: install a local module '
|
||||
'(`chain install --link <path>`), add the store that provides '
|
||||
'them (Settings → Stores), or configure the integration '
|
||||
'(MCP/n8n) that supplies the capability.',
|
||||
'Dieser Flow braucht Capabilities, die kein eingerichteter Store '
|
||||
'installieren kann:\n$caps\n'
|
||||
'Wege: lokales Modul installieren '
|
||||
'(`chain install --link <pfad>`), den passenden Store '
|
||||
'hinzufügen (Einstellungen → Stores) oder die Anbindung '
|
||||
'(MCP/n8n) einrichten, die die Capability bereitstellt.',
|
||||
);
|
||||
|
||||
// Flow-list filter (first iteration: plain substring match).
|
||||
String get listFilterHint => _t('Filter flows…', 'Flows filtern…');
|
||||
|
|
@ -387,8 +408,9 @@ class AnalyzerStrings {
|
|||
'Unknown capability "$cap". Did you mean "$suggestion"?';
|
||||
static String _enUnknownCapNotInStore(String cap) =>
|
||||
'Unknown capability "$cap". '
|
||||
'Not in the store — install locally with '
|
||||
'`chain install --link <path>` or check the spelling.';
|
||||
'No configured store can install it — install a local module '
|
||||
'(`chain install --link <path>`), add the store that provides '
|
||||
'it, or configure the integration (MCP/n8n) that supplies it.';
|
||||
static String _enUnknownType(String kind, String value, String validList) =>
|
||||
'Unknown $kind type "$value". Use one of: $validList.';
|
||||
static String _enInputKind() => 'input';
|
||||
|
|
|
|||
|
|
@ -73,8 +73,8 @@ class InstallCapabilityFix extends QuickFix {
|
|||
/// (`chain install <url>`), then installs and reanalyzes.
|
||||
///
|
||||
/// This is the recovery path for private modules: the public
|
||||
/// store doesn't know about `htw.digiscout/onet-lookup`, but
|
||||
/// the operator can point the hub at the local clone.
|
||||
/// store doesn't know about `acme.internal/directory-lookup`,
|
||||
/// but the operator can point the hub at the local clone.
|
||||
@immutable
|
||||
class AddModuleSourceFix extends QuickFix {
|
||||
/// The capability the operator wrote — the host uses it to
|
||||
|
|
|
|||
154
lib/src/widgets/missing_modules_badge.dart
Normal file
154
lib/src/widgets/missing_modules_badge.dart
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
// Flow-list badge for flows whose steps reference capabilities
|
||||
// the hub doesn't provide. Split out of flow_editor_page.dart so
|
||||
// the resolvability states (installable from a store vs. not in
|
||||
// any store) stay unit-testable.
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../l10n.dart';
|
||||
import '../tokens.dart';
|
||||
|
||||
/// Split a flow's missing capabilities into the ones a configured
|
||||
/// store can actually install and the ones no store resolves.
|
||||
///
|
||||
/// [storeNames] is the host-supplied set of bare capability names
|
||||
/// the store can install (already filtered to installable entries
|
||||
/// — published/alpha, native). An empty set therefore means
|
||||
/// "nothing is store-installable", not "unknown": the honest
|
||||
/// state without store data is the not-in-store explanation, and
|
||||
/// the local-install / add-store / configure-integration paths
|
||||
/// remain available.
|
||||
({List<String> installable, List<String> notInStore}) splitMissingCaps(
|
||||
List<String> missing,
|
||||
Set<String> storeNames,
|
||||
) {
|
||||
final installable = <String>[];
|
||||
final notInStore = <String>[];
|
||||
for (final cap in missing) {
|
||||
final bare = cap.split('@').first;
|
||||
(storeNames.contains(bare) ? installable : notInStore).add(cap);
|
||||
}
|
||||
return (installable: installable, notInStore: notInStore);
|
||||
}
|
||||
|
||||
/// "N modules missing" status on flows whose steps reference
|
||||
/// capabilities the hub doesn't provide. Status and action are
|
||||
/// SEPARATE elements (usertest: the combined orange chip made
|
||||
/// the whole list read like an error wall and ellipsized the
|
||||
/// action word):
|
||||
/// - status = quiet neutral chip with a small amber dot — a
|
||||
/// note, not an alarm; warning orange stays reserved for real
|
||||
/// failures.
|
||||
/// - action = its own "Install" link that is never truncated;
|
||||
/// both live in the row's Wrap, so tight widths wrap to a
|
||||
/// second line instead of cutting text mid-word.
|
||||
///
|
||||
/// The install action only covers [installable] — capabilities a
|
||||
/// configured store resolves. [notInStore] capabilities get their
|
||||
/// own quiet chip whose tooltip explains the three recovery paths
|
||||
/// (local install / add store / configure integration) BEFORE any
|
||||
/// click, instead of an install button that would end in the
|
||||
/// hub's "no store entry" error.
|
||||
class MissingModulesBadge extends StatelessWidget {
|
||||
final List<String> installable;
|
||||
final List<String> notInStore;
|
||||
final FlowEditorStrings strings;
|
||||
final VoidCallback? onInstall;
|
||||
const MissingModulesBadge({
|
||||
super.key,
|
||||
required this.installable,
|
||||
required this.notInStore,
|
||||
required this.strings,
|
||||
required this.onInstall,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final children = <Widget>[
|
||||
if (installable.isNotEmpty)
|
||||
_chip(
|
||||
theme,
|
||||
dot: const Color(0xFFEF6C00),
|
||||
label: strings.flowListNeedsModules(installable.length),
|
||||
tooltip: strings.flowListNeedsModulesTooltip(
|
||||
installable.join('\n'),
|
||||
),
|
||||
),
|
||||
if (installable.isNotEmpty && onInstall != null)
|
||||
InkWell(
|
||||
onTap: onInstall,
|
||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
|
||||
child: Text(
|
||||
strings.flowListInstallMissing,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (notInStore.isNotEmpty)
|
||||
_chip(
|
||||
theme,
|
||||
// Muted dot: this state has no one-click fix, so it
|
||||
// must not borrow the actionable chip's amber.
|
||||
dot: theme.colorScheme.outline,
|
||||
label: strings.flowListNotInStore(notInStore.length),
|
||||
tooltip: strings.flowListNotInStoreTooltip(notInStore.join('\n')),
|
||||
),
|
||||
];
|
||||
if (children.length == 1) return children.single;
|
||||
return Wrap(
|
||||
spacing: FaiSpace.xs,
|
||||
runSpacing: FaiSpace.xs,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: children,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _chip(
|
||||
ThemeData theme, {
|
||||
required Color dot,
|
||||
required String label,
|
||||
required String tooltip,
|
||||
}) {
|
||||
final fg = theme.colorScheme.onSurfaceVariant;
|
||||
return Tooltip(
|
||||
message: tooltip,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(FaiRadius.sm),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(color: dot, shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: fg,
|
||||
fontSize: 10,
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
name: chain_studio_flow_editor
|
||||
description: Swappable inline YAML editor for F∆I Studio flows.
|
||||
version: 0.24.1
|
||||
version: 0.25.0
|
||||
publish_to: 'none'
|
||||
repository: https://git.flemming.ai/fai/studio-flow-editor
|
||||
|
||||
|
|
|
|||
|
|
@ -135,13 +135,20 @@ steps:
|
|||
name: x
|
||||
steps:
|
||||
- id: c
|
||||
use: htw.private/secret@^0.1
|
||||
use: acme.internal/secret@^0.1
|
||||
'''));
|
||||
expect(r.issues, hasLength(1));
|
||||
final fixes = a.fixesFor(r.issues.first);
|
||||
expect(fixes, hasLength(1));
|
||||
expect(fixes.first, isA<AddModuleSourceFix>());
|
||||
expect(r.issues.first.message, contains('Not in the store'));
|
||||
// The message must explain all three recovery paths in place:
|
||||
// local install, adding a store, configuring an integration.
|
||||
expect(
|
||||
r.issues.first.message,
|
||||
contains('No configured store can install it'),
|
||||
);
|
||||
expect(r.issues.first.message, contains('chain install --link'));
|
||||
expect(r.issues.first.message, contains('integration'));
|
||||
});
|
||||
|
||||
test('did-you-mean wins over install/add-source when a near miss exists',
|
||||
|
|
|
|||
103
test/missing_modules_badge_test.dart
Normal file
103
test/missing_modules_badge_test.dart
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:chain_studio_flow_editor/src/l10n.dart';
|
||||
import 'package:chain_studio_flow_editor/src/widgets/missing_modules_badge.dart';
|
||||
|
||||
void main() {
|
||||
group('splitMissingCaps', () {
|
||||
test('separates store-resolvable from not-in-store capabilities', () {
|
||||
final split = splitMissingCaps(
|
||||
['text.extract', 'example-provider/tool.summarize'],
|
||||
{'text.extract', 'debug.echo'},
|
||||
);
|
||||
expect(split.installable, ['text.extract']);
|
||||
expect(split.notInStore, ['example-provider/tool.summarize']);
|
||||
});
|
||||
|
||||
test('matches on the bare name when the flow pins a version', () {
|
||||
final split = splitMissingCaps(['text.extract@^0'], {'text.extract'});
|
||||
expect(split.installable, ['text.extract@^0']);
|
||||
expect(split.notInStore, isEmpty);
|
||||
});
|
||||
|
||||
test('empty store set classifies everything as not-in-store', () {
|
||||
final split = splitMissingCaps(['text.extract'], {});
|
||||
expect(split.installable, isEmpty);
|
||||
expect(split.notInStore, ['text.extract']);
|
||||
});
|
||||
});
|
||||
|
||||
Future<void> pumpBadge(
|
||||
WidgetTester tester, {
|
||||
required List<String> installable,
|
||||
required List<String> notInStore,
|
||||
VoidCallback? onInstall,
|
||||
}) {
|
||||
return tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: MissingModulesBadge(
|
||||
installable: installable,
|
||||
notInStore: notInStore,
|
||||
strings: const FlowEditorStrings(FlowEditorLocale.en),
|
||||
onInstall: onInstall,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
group('MissingModulesBadge', () {
|
||||
testWidgets('store-resolvable capability offers the install action', (
|
||||
tester,
|
||||
) async {
|
||||
var installed = false;
|
||||
await pumpBadge(
|
||||
tester,
|
||||
installable: ['text.extract'],
|
||||
notInStore: [],
|
||||
onInstall: () => installed = true,
|
||||
);
|
||||
expect(find.text('1 module missing'), findsOneWidget);
|
||||
expect(find.text('Install'), findsOneWidget);
|
||||
expect(find.textContaining('not in store'), findsNothing);
|
||||
await tester.tap(find.text('Install'));
|
||||
expect(installed, isTrue);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'not-in-store capability shows the classified state, no install',
|
||||
(tester) async {
|
||||
await pumpBadge(
|
||||
tester,
|
||||
installable: [],
|
||||
notInStore: ['example-provider/tool.summarize'],
|
||||
onInstall: () => fail('no install action for not-in-store caps'),
|
||||
);
|
||||
expect(find.text('not in store'), findsOneWidget);
|
||||
expect(find.text('Install'), findsNothing);
|
||||
// The recovery paths are explained in place, before any click.
|
||||
final tooltip = tester.widget<Tooltip>(find.byType(Tooltip));
|
||||
expect(tooltip.message, contains('example-provider/tool.summarize'));
|
||||
expect(tooltip.message, contains('chain install --link'));
|
||||
expect(tooltip.message, contains('Settings → Stores'));
|
||||
expect(tooltip.message, contains('integration'));
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('mixed state renders both chips, install covers store caps', (
|
||||
tester,
|
||||
) async {
|
||||
await pumpBadge(
|
||||
tester,
|
||||
installable: ['text.extract'],
|
||||
notInStore: ['example-provider/tool.summarize'],
|
||||
onInstall: () {},
|
||||
);
|
||||
expect(find.text('1 module missing'), findsOneWidget);
|
||||
expect(find.text('Install'), findsOneWidget);
|
||||
expect(find.text('not in store'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue