chain-studio/test/approvals_origin_test.dart
flemming-it f7d7427d91 fix(approvals): usertest-panel hardening (0.81.0)
Panel findings against the reworked approvals page, fixed in place:

- Never fabricate the request time: ApprovalRecord.createdAt is
  nullable now; a missing created_at omits the line instead of
  rendering DateTime.now() (which drifted on refresh). Guard:
  approvals_origin_test pins the omit-on-null invariant.
- Copyable errors on approve/reject/batch via showChainErrorSnack
  (the hard project rule) — batch surfaces the first real cause.
- Reject requires a reason: ChainInlineHelp strip + confirm disabled
  while empty, no more silent close-and-nothing-happens.
- Batch approve applies the same no-data confirmation as the single
  path, naming how many selected requests carry no show: data.
- Plainer language: glossary "Vorgang (Flow)", history label FRAGE
  (was PROMPT), no-data hint drops developer jargon.
- One-click copy of the run id; history payload pretty-prints like
  the card.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-07-26 16:05:55 +02:00

125 lines
4.5 KiB
Dart

// 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.
//
// It also pins the honesty invariant from the usertest: a missing
// created_at must NOT be rendered as a fabricated "now" — the line
// is simply omitted, exactly like the run id on a legacy hub.
//
// 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();
}
ApprovalRecord _pending({DateTime? createdAt, String? runId = 'run-abc123'}) {
return ApprovalRecord(
id: 'apr-1',
flowName: 'classify-and-file',
stepId: 'review',
prompt: 'Bitte die Klassifikation prüfen',
payloadPreview: '{"label":"Rechnung"}',
createdAt: createdAt,
expiresAt: null,
status: 'pending',
decidedAt: null,
decidedBy: '',
reason: '',
project: 'lbs',
flowExecution: runId,
);
}
Future<String> _renderApprovals(
WidgetTester tester,
List<ApprovalRecord> approvals,
) async {
final fake = installFakeHub();
fake.approvals = approvals;
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);
await tester.pumpWidget(const SizedBox.shrink());
await tester.pump(const Duration(minutes: 1));
return text;
}
void main() {
testWidgets('a pending approval card shows its full origin and an '
'intro explaining approve / reject', (tester) async {
final text = await _renderApprovals(tester, [
_pending(createdAt: DateTime.utc(2026, 7, 26, 12, 0, 0)),
]);
// 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'));
});
testWidgets('a missing requested-at time is omitted, never faked as '
'"now"', (tester) async {
// created_at unknown: the honest behaviour is to drop the line,
// not to invent a timestamp (which would even drift on refresh).
final text = await _renderApprovals(tester, [_pending(createdAt: null)]);
// The rest of the origin still renders.
expect(text, contains('HERKUNFT'));
expect(text, contains('Flow: classify-and-file'));
expect(text, contains('Lauf: run-abc123'));
// But no requested-at line at all.
expect(text, isNot(contains('Angefordert:')));
});
testWidgets('a legacy approval without a run id omits the run line', (
tester,
) async {
final text = await _renderApprovals(tester, [
_pending(createdAt: DateTime.utc(2026, 7, 26, 12, 0, 0), runId: null),
]);
expect(text, contains('HERKUNFT'));
expect(text, isNot(contains('Lauf:')));
});
}