chain-studio-flow-editor/test/flow_analyzer_test.dart
flemming-it ab97e5e834 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>
2026-07-22 13:42:11 +02:00

229 lines
7 KiB
Dart

// Behavioural tests for FlowAnalyzer — the YAML-aware
// AbstractAnalyzer that feeds the flow editor's gutter + the
// custom CodeController's wavy-underline overrides.
import 'package:chain_studio_flow_editor/src/flow_analyzer.dart';
import 'package:chain_studio_flow_editor/src/quick_fix.dart';
import 'package:flutter_code_editor/flutter_code_editor.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:highlight/languages/yaml.dart';
Code _wrap(String text) => Code(text: text, language: yaml);
void main() {
group('FlowAnalyzer', () {
test('empty buffer reports no issues', () async {
final a = FlowAnalyzer(availableCapabilities: () => const []);
final r = await a.analyze(_wrap(''));
expect(r.issues, isEmpty);
});
test('valid hello flow reports no issues', () async {
final a = FlowAnalyzer(
availableCapabilities: () => const ['debug.echo'],
);
final r = await a.analyze(_wrap('''
name: hello
inputs:
name: text
steps:
- id: greet
use: debug.echo@^0
with:
message: \$inputs.name
outputs:
greeting: \$greet.echoed
'''));
expect(r.issues, isEmpty);
});
test('flags unknown capability on the use line', () async {
final a = FlowAnalyzer(
availableCapabilities: () => const ['debug.echo'],
);
final r = await a.analyze(_wrap('''
name: hello
inputs:
msg: text
steps:
- id: t
use: text.translate@^1
outputs: {}
'''));
expect(r.issues, hasLength(1));
expect(r.issues.first.message, contains('text.translate'));
expect(r.issues.first.type, IssueType.error);
});
test('flags unknown input type', () async {
final a = FlowAnalyzer(availableCapabilities: () => const []);
final r = await a.analyze(_wrap('''
name: hello
inputs:
pdf: byes
steps: []
outputs: {}
'''));
expect(r.issues, hasLength(1));
expect(r.issues.first.message, contains('byes'));
expect(r.issues.first.type, IssueType.warning);
});
test('YAML parse error pins to the offending line', () async {
final a = FlowAnalyzer(availableCapabilities: () => const []);
// Unterminated quoted string — a real parse error rather
// than a structural oddity YAML happens to tolerate.
final r = await a.analyze(_wrap('name: "unterminated\n'));
expect(r.issues, hasLength(1));
expect(r.issues.first.type, IssueType.error);
expect(r.issues.first.message, startsWith('YAML:'));
});
test('matches the bare capability id ignoring @version', () async {
final a = FlowAnalyzer(
availableCapabilities: () => const ['text.echo'],
);
final r = await a.analyze(_wrap('''
name: x
steps:
- id: e
use: text.echo@^2
'''));
expect(r.issues, isEmpty);
});
test('empty installed list silences capability checks', () async {
// When the host hasn't supplied a list (Studio still
// booting, or running in CLI-only mode), we deliberately
// don't false-positive every `use:` row.
final a = FlowAnalyzer(availableCapabilities: () => const []);
final r = await a.analyze(_wrap('''
steps:
- id: e
use: text.echo@^2
'''));
expect(r.issues, isEmpty);
});
test('attaches InstallCapabilityFix when the capability is in the store',
() async {
final a = FlowAnalyzer(
availableCapabilities: () => const ['debug.echo'],
storeCapabilities: () => const ['text.classify'],
);
final r = await a.analyze(_wrap('''
name: x
steps:
- id: c
use: text.classify@^1
'''));
expect(r.issues, hasLength(1));
final fixes = a.fixesFor(r.issues.first);
expect(fixes, hasLength(1));
final fix = fixes.first;
expect(fix, isA<InstallCapabilityFix>());
expect((fix as InstallCapabilityFix).capability, 'text.classify@^1');
});
test('falls back to AddModuleSourceFix when unknown and not in store',
() async {
final a = FlowAnalyzer(
availableCapabilities: () => const ['debug.echo'],
storeCapabilities: () => const ['text.classify'],
);
final r = await a.analyze(_wrap('''
name: x
steps:
- id: c
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>());
// 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',
() async {
// Installed bare `text.echo`; user typed `text.echi` (edit-distance 1).
// The fix list should carry the Replace-as-primary; no install or
// add-source needed because the analyzer already found the answer.
final a = FlowAnalyzer(
availableCapabilities: () => const ['text.echo@^0.1'],
storeCapabilities: () => const [],
);
final r = await a.analyze(_wrap('''
name: x
steps:
- id: t
use: text.echi@^0.1
'''));
expect(r.issues, hasLength(1));
final fixes = a.fixesFor(r.issues.first);
expect(fixes.first, isA<ReplaceLineValueFix>());
// Replacement preserves the version constraint by reusing the
// installed entry's `@^0.1`.
expect(
(fixes.first as ReplaceLineValueFix).replacement,
'text.echo@^0.1',
);
});
test('records worst severity per step for canvas highlighting',
() async {
final a = FlowAnalyzer(
availableCapabilities: () => const ['debug.echo'],
storeCapabilities: () => const [],
);
await a.analyze(_wrap('''
name: x
inputs:
pdf: byes
steps:
- id: bad
use: nope@^1
'''));
expect(a.stepSeverity['bad'], IssueType.error);
expect(a.stepSeverity[kInputsNodeId], IssueType.warning);
});
test('suggests closest valid type for a misspelled value', () async {
final a = FlowAnalyzer(availableCapabilities: () => const []);
final r = await a.analyze(_wrap('''
name: x
inputs:
pdf: byes
steps: []
'''));
expect(r.issues, hasLength(1));
final fixes = a.fixesFor(r.issues.first);
expect(fixes, hasLength(1));
final fix = fixes.first;
expect(fix, isA<ReplaceLineValueFix>());
expect((fix as ReplaceLineValueFix).replacement, 'bytes');
expect(fix.line, r.issues.first.line);
});
test('emits no fix when the typo is far from every known type',
() async {
final a = FlowAnalyzer(availableCapabilities: () => const []);
final r = await a.analyze(_wrap('''
name: x
inputs:
pdf: zonglefax
steps: []
'''));
expect(r.issues, hasLength(1));
final fixes = a.fixesFor(r.issues.first);
expect(fixes, isEmpty);
});
});
}