chain-studio/test/guided_setup_test.dart
flemming-it ddac84ce8e feat: guided setup — persona re-audit fixes (grade-1 round)
- Regulated path finishes without a terminal: the signed-source
  state offers 'Add a signed source…' (stores dialog with pin-a-key)
  plus the per-module install buttons and a plain-language hint why
  pinning the publisher's key matters — instead of a hint with no
  affordance.
- Apply warnings (e.g. the empty-trusted-publishers caveat) surface
  selectable in the done state instead of being swallowed.
- Truthful preview: new lines state which machine is being set up
  (server/container targets configure THIS machine), that regulated
  profiles always get the hash-chained audit log (even with WORM
  off), and that the curated reading list is stored with the setup
  record.
- Language pass: onboarding checklist in Sie-form + 'System-KI'
  (was du-form + 'System-AI'), 'Audit-Sperre' jargon replaced,
  answers file moved to a private per-dialog temp dir.
- Screenshot harness: GUIDE_SHOTS_THEME=light for light-parity
  proof runs.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-07-13 01:20:59 +02:00

292 lines
10 KiB
Dart

// Guided-setup wizard — the three explained answer steps. Verifies
// that options render as localized cards with their one-line
// explanation (no English enum humanization) and that Weiter/Zurück
// walk the steps. The review step calls the CLI (a subprocess) and is
// out of scope for a widget test.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:chain_studio/l10n/app_localizations.dart';
import 'package:chain_studio/widgets/guided_setup_dialog.dart';
Widget _host() => MaterialApp(
locale: const Locale('de'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (context) => Scaffold(
body: Center(
child: ElevatedButton(
onPressed: () => GuidedSetupDialog.show(context),
child: const Text('open'),
),
),
),
),
);
/// Host that opens the dialog pre-seeded on the review / applied
/// step with [plan] (the parsed `--plan-json` shape), bypassing the
/// CLI subprocess.
Widget _seededHost(Map<String, dynamic> plan, {bool applied = false}) =>
MaterialApp(
locale: const Locale('de'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (context) => Scaffold(
body: Center(
child: ElevatedButton(
onPressed: () => showDialog<void>(
context: context,
builder: (_) =>
GuidedSetupDialog(debugPlan: plan, debugApplied: applied),
),
child: const Text('open'),
),
),
),
),
);
Map<String, dynamic> _regulatedPlan({bool signed = true}) => {
'profile': 'enterprise',
'modules': ['text.extract', 'text.summarize'],
'starter_flow': 'extract-summarize',
'runbook': 'service',
'curated_docs': <String>[],
'require_signatures': signed,
'worm_audit': false,
'approval_step': true,
};
void main() {
testWidgets('step 1 shows scenario options in German with explanations', (
tester,
) async {
await tester.pumpWidget(_host());
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
// Step counter + localized (German) scenario labels, not the
// English enum humanization.
expect(find.text('Schritt 1 von 3'), findsOneWidget);
expect(find.text('Regulierter Produktivbetrieb'), findsOneWidget);
expect(find.text('Erst mal ausprobieren'), findsOneWidget);
// Each option carries its one-line explanation.
expect(
find.textContaining('menschliche Freigabe vor jedem KI-Schritt'),
findsOneWidget,
);
// No leaked English enum labels.
expect(find.text('Regulated Production'), findsNothing);
expect(find.text('Trying Out'), findsNothing);
});
testWidgets('Weiter/Zurück walk the three answer steps', (tester) async {
await tester.pumpWidget(_host());
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
// Step 1 → 2 (task).
await tester.tap(find.text('Weiter'));
await tester.pumpAndSettle();
expect(find.text('Schritt 2 von 3'), findsOneWidget);
expect(find.text('Text aus Dokumenten extrahieren'), findsOneWidget);
// Step 2 → 3 (environment) shows the two plain-language toggles.
await tester.tap(find.text('Weiter'));
await tester.pumpAndSettle();
expect(find.text('Schritt 3 von 3'), findsOneWidget);
expect(find.text('Dieser Rechner'), findsOneWidget);
expect(
find.text('Vor jedem KI-Schritt einen Menschen um Freigabe bitten'),
findsOneWidget,
);
// Zurück returns to step 2.
await tester.tap(find.text('Zurück'));
await tester.pumpAndSettle();
expect(find.text('Schritt 2 von 3'), findsOneWidget);
});
testWidgets(
'review of a regulated plan explains the signature dead end and offers the relaxation switch',
(tester) async {
await tester.pumpWidget(_seededHost(_regulatedPlan()));
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
// The plain-language notice + the deliberate one-click switch.
expect(
find.textContaining('Der öffentliche Store liefert zurzeit unsignierte Pakete'),
findsOneWidget,
);
expect(
find.text('Installation aus dem öffentlichen Store erlauben'),
findsOneWidget,
);
// No raw CLI prose anywhere.
expect(find.textContaining('chain install'), findsNothing);
},
);
testWidgets(
'applied state renders clickable next steps — start hub, per-module install, open flow',
(tester) async {
await tester.pumpWidget(
_seededHost(_regulatedPlan(signed: false), applied: true),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
expect(find.text('Fertig — Ch∆In ist eingerichtet.'), findsOneWidget);
// Hub not probed up in tests → the start action + the
// install-needs-hub hint show, and install buttons exist
// (disabled) for each plan module.
expect(find.text('Hub starten'), findsOneWidget);
expect(
find.textContaining('Starten Sie zuerst den Hub'),
findsOneWidget,
);
expect(find.text('text.extract installieren'), findsOneWidget);
expect(find.text('text.summarize installieren'), findsOneWidget);
expect(
find.text('Beispiel-Flow „extract-summarize“ öffnen'),
findsOneWidget,
);
// No CLI text in the GUI path.
expect(find.textContaining('chain serve'), findsNothing);
expect(find.textContaining('chain install'), findsNothing);
},
);
testWidgets(
'applied regulated (signed) state stays clickable — signed-source dialog, trust hint, install buttons',
(tester) async {
await tester.pumpWidget(_seededHost(_regulatedPlan(), applied: true));
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
// The plain-language story + the pin-the-publisher trust hint.
expect(find.textContaining('signierten Quelle'), findsOneWidget);
expect(
find.textContaining('Schlüssel des Herausgebers'),
findsOneWidget,
);
// No dead end: the stores dialog (with its pin-a-key field) is
// one click away, and the installs stay available for after
// the source is added.
expect(find.text('Signierte Quelle hinzufügen…'), findsOneWidget);
expect(find.text('text.extract installieren'), findsOneWidget);
expect(find.textContaining('chain store add'), findsNothing);
},
);
testWidgets(
'review preview names the machine being set up and the audit chain',
(tester) async {
await tester.pumpWidget(_seededHost(_regulatedPlan()));
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
// Runbook honesty: a "home server" target still configures
// THIS machine — the preview must say so.
expect(
find.textContaining('Eingerichtet wird dieser Rechner'),
findsOneWidget,
);
// The regulated promise is stated even when WORM is off:
// the hash-chained audit log line is always there.
expect(
find.textContaining('hash-verketteten Prüfprotokoll'),
findsOneWidget,
);
},
);
testWidgets(
'step 1 offers the free-text path with an honest no-AI fallback hint',
(tester) async {
await tester.pumpWidget(
MaterialApp(
locale: const Locale('de'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (context) => Scaffold(
body: Center(
child: ElevatedButton(
onPressed: () => showDialog<void>(
context: context,
builder: (_) =>
const GuidedSetupDialog(debugSkipAiProbe: true),
),
child: const Text('open'),
),
),
),
),
),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
expect(
find.text('Oder beschreiben Sie einfach, was Sie vorhaben'),
findsOneWidget,
);
// No system AI configured → honest fallback: the menu always
// works; no dead end, no disabled mystery button.
expect(
find.textContaining('Die Auswahl oben funktioniert immer'),
findsOneWidget,
);
expect(find.text('Vorschlagen lassen'), findsNothing);
},
);
group('parseSetupSuggestion', () {
test('accepts a valid JSON object embedded in prose', () {
final r = parseSetupSuggestion(
'Sure! Here is the mapping:\n'
'{"scenario":"regulated-production","intent":"classify-documents",'
'"target":"home-server","require_approval":true,'
'"data_must_stay_local":false}\n'
'Let me know if you need anything else.',
);
expect(r, isNotNull);
expect(r!['scenario'], 'regulated-production');
expect(r['intent'], 'classify-documents');
expect(r['target'], 'home-server');
expect(r['require_approval'], true);
expect(r['data_must_stay_local'], false);
});
test('rejects hallucinated enum values', () {
expect(
parseSetupSuggestion(
'{"scenario":"world-domination","intent":"hello-world",'
'"target":"this-laptop"}',
),
isNull,
);
});
test('rejects non-JSON replies', () {
expect(parseSetupSuggestion('I cannot help with that.'), isNull);
expect(parseSetupSuggestion('{broken json'), isNull);
});
test('missing booleans default to false', () {
final r = parseSetupSuggestion(
'{"scenario":"trying-out","intent":"hello-world",'
'"target":"this-laptop"}',
);
expect(r, isNotNull);
expect(r!['require_approval'], false);
expect(r['data_must_stay_local'], false);
});
});
}