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:
flemming-it 2026-07-22 13:42:11 +02:00
parent 7d6a575cae
commit ab97e5e834
8 changed files with 340 additions and 105 deletions

View 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);
});
});
}