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:
parent
14f824b8ef
commit
28f6fe1a9a
16 changed files with 613 additions and 115 deletions
90
test/approvals_origin_test.dart
Normal file
90
test/approvals_origin_test.dart
Normal 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));
|
||||
});
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue