feat(approvals,runs): explain approvals in place + one-click hub update (0.81.0)

Approvals: the pending card now shows its full origin — flow, step,
run id (previously dropped at the Dart mapping layer), project, and
requested-at — under an ORIGIN heading, led by a one-line intro strip
that says what the inbox is and what Approve/Reject do. Approve/Reject
buttons carry tooltips; the history dialog gains project + run id.
Fixes the approvals doc drift (title/details/reviewer ->
prompt/show/timeout_seconds). Guard: approvals_origin_test renders the
card via the hermetic fake hub and pins every origin fact.

Runs: the "hub too old" state now leads with an in-place update button
(same `chain update apply` path as the Diagnose page), the Diagnose
deeplink demoted to secondary, with a CLI-absent fallback. Guard: two
new RunsLoadErrorView widget tests.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
flemming-it 2026-07-26 15:50:41 +02:00
parent 14f824b8ef
commit 28f6fe1a9a
16 changed files with 613 additions and 115 deletions

View file

@ -0,0 +1,90 @@
// Approvals origin block a reviewer must be able to see WHERE a
// pending approval comes from (which flow, step, run, project, and
// when it was requested) and WHAT approve / reject will do, without
// leaving the card. The run id in particular used to be dropped at
// the Dart mapping layer; this pins that it reaches the card, next
// to the rest of the origin facts and the plain-language intro.
//
// Runs against the scriptable FakeHubService never a real hub.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:chain_studio/data/hub.dart';
import 'package:chain_studio/main.dart';
import 'support/fake_hub.dart';
/// Every rendered Text + SelectableText joined the run id is a
/// SelectableText (copyable), so a Text-only sweep would miss it.
String _allText(WidgetTester tester) {
final buf = StringBuffer();
for (final w in tester.widgetList<Text>(find.byType(Text))) {
buf.writeln(w.data ?? w.textSpan?.toPlainText() ?? '');
}
for (final w in tester.widgetList<SelectableText>(
find.byType(SelectableText),
)) {
buf.writeln(w.data ?? '');
}
return buf.toString();
}
void main() {
testWidgets('a pending approval card shows its full origin and an '
'intro explaining approve / reject', (tester) async {
final fake = installFakeHub();
fake.approvals = [
ApprovalRecord(
id: 'apr-1',
flowName: 'classify-and-file',
stepId: 'review',
prompt: 'Bitte die Klassifikation prüfen',
payloadPreview: '{"label":"Rechnung"}',
createdAt: DateTime.utc(2026, 7, 26, 12, 0, 0),
expiresAt: null,
status: 'pending',
decidedAt: null,
decidedBy: '',
reason: '',
project: 'lbs',
flowExecution: 'run-abc123',
),
];
SharedPreferences.setMockInitialValues({});
tester.view.physicalSize = const Size(1280, 900);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
await tester.pumpWidget(
const StudioApp(
initialThemeMode: ThemeModeValue.dark,
initialLocale: Locale('de'),
),
);
await tester.pump(const Duration(milliseconds: 100));
await tester.tap(find.byKey(const ValueKey('sidebar-item-approvals')));
for (var i = 0; i < 12; i++) {
await tester.pump(const Duration(milliseconds: 200));
}
final text = _allText(tester);
// The human question (headline).
expect(text, contains('Bitte die Klassifikation prüfen'));
// The origin block every fact a reviewer needs to place it.
expect(text, contains('HERKUNFT'));
expect(text, contains('Flow: classify-and-file'));
expect(text, contains('Schritt: review'));
expect(text, contains('Projekt: lbs'));
expect(text, contains('Angefordert:'));
// The run id the field that used to be dropped at the mapping.
expect(text, contains('Lauf: run-abc123'));
// The intro strip says what this inbox is and what the actions do.
expect(text, contains('pausierte Flows'));
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump(const Duration(minutes: 1));
});
}

View file

@ -135,6 +135,53 @@ void main() {
expect(doctorOpened, isTrue);
});
testWidgets('too-old hub leads with an in-place update button, '
'doctor demoted to secondary', (tester) async {
var updateTriggered = false;
await tester.pumpWidget(
_host(
RunsLoadErrorView(
error: const _FakeGrpcError(12, 'unimplemented'),
issue: RunsLoadIssue.unsupported,
onOpenGuide: () {},
onOpenDoctor: () {},
onUpdateHub: () => updateTriggered = true,
),
),
);
await tester.pumpAndSettle();
// Primary CTA is the fix, not the detour.
expect(find.text('Hub jetzt aktualisieren'), findsOneWidget);
expect(find.text('Diagnose öffnen'), findsOneWidget);
await tester.tap(find.text('Hub jetzt aktualisieren'));
expect(updateTriggered, isTrue);
});
testWidgets('while updating, the button shows progress and is disabled', (
tester,
) async {
var updateTriggered = false;
await tester.pumpWidget(
_host(
RunsLoadErrorView(
error: const _FakeGrpcError(12, 'unimplemented'),
issue: RunsLoadIssue.unsupported,
onOpenGuide: () {},
onOpenDoctor: () {},
onUpdateHub: () => updateTriggered = true,
updating: true,
),
),
);
// The spinner animates forever, so pump one frame rather than
// pumpAndSettle (which would time out waiting for it to stop).
await tester.pump();
expect(find.text('Hub wird aktualisiert…'), findsOneWidget);
// Disabled while in flight a tap must not re-fire.
await tester.tap(find.byType(FilledButton), warnIfMissed: false);
expect(updateTriggered, isFalse);
});
testWidgets('a genuinely unreachable hub still says unreachable', (
tester,
) async {

View file

@ -65,6 +65,11 @@ class FakeHubService extends Fake implements HubService {
bool detachedEnabled = false;
bool probeServing = true;
/// Approvals returned by [listApprovalsRecords], filtered by the
/// requested statuses so the pending / history tabs each see the
/// right slice. Empty by default (healthy-empty inbox).
List<ApprovalRecord> approvals = const [];
void failWith(Object error, {Set<String>? only}) {
if (only == null) {
failAll = error;
@ -209,7 +214,12 @@ class FakeHubService extends Fake implements HubService {
List<String> statuses = const [],
int limit = 200,
String project = '',
}) => _async('listApprovalsRecords', () => const []);
}) => _async(
'listApprovalsRecords',
() => statuses.isEmpty
? approvals
: approvals.where((a) => statuses.contains(a.status)).toList(),
);
@override
Future<({List<DetachedRun> runs, bool enabled})> listDetachedRuns({