chain-studio/test/progress_bar_test.dart
flemming-it 2bc556e0f5 feat(studio): ChainProgressBar - value and sign of life, kept apart
A waiting user asks two questions that a plain LinearProgressIndicator
answers as one: how far along is it, and is anything still happening.
The widget keeps them apart - the value may stand still for minutes
while a highlight driven by 'busy' keeps crossing the bar, so a
stalled percentage no longer reads as a frozen app.

Also: glides to new values over 600 ms so the polling interval behind
it stays invisible, refuses to walk backwards, renders indeterminate
rather than an empty bar when no value is known, stops animating once
the work ends, and honours reduce-motion.

Eight guards in test/progress_bar_test.dart cover each of those rules.
Writing them found a real defect: with busy: false the late-final
controller was first constructed inside dispose(), where the ticker's
context lookup is already unsafe.

Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
2026-09-08 13:20:19 +02:00

113 lines
4.1 KiB
Dart

// Guards for ChainProgressBar — the rules from shared/PROGRESS.md that
// are easy to break by accident when someone "simplifies" the widget.
import 'package:chain_studio/widgets/chain_progress_bar.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
Widget _host(Widget child, {bool disableAnimations = false}) => MaterialApp(
home: Scaffold(
body: MediaQuery(
data: MediaQueryData(disableAnimations: disableAnimations),
child: Padding(padding: const EdgeInsets.all(20), child: child),
),
),
);
LinearProgressIndicator _indicator(WidgetTester tester) =>
tester.widget<LinearProgressIndicator>(
find.byType(LinearProgressIndicator),
);
void main() {
testWidgets('no value renders indeterminate, never an empty bar',
(tester) async {
await tester.pumpWidget(_host(const ChainProgressBar(stage: 'Verbinden')));
await tester.pump(const Duration(milliseconds: 100));
expect(_indicator(tester).value, isNull,
reason: 'a null value must stay indeterminate, not render as 0');
expect(find.text('Verbinden'), findsOneWidget);
});
testWidgets('a value glides in and is shown as a rounded percentage',
(tester) async {
await tester.pumpWidget(
_host(const ChainProgressBar(value: 0.42, stage: 'Herunterladen')),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 700));
expect(_indicator(tester).value, closeTo(0.42, 0.001));
expect(find.text('42 %'), findsOneWidget);
});
testWidgets('the value never walks backwards', (tester) async {
await tester.pumpWidget(_host(const ChainProgressBar(value: 0.6)));
await tester.pump(const Duration(milliseconds: 700));
expect(_indicator(tester).value, closeTo(0.6, 0.001));
// A recomputation that comes out lower must be ignored: a bar that
// jumps back destroys trust for the rest of the session.
await tester.pumpWidget(_host(const ChainProgressBar(value: 0.2)));
await tester.pump(const Duration(milliseconds: 700));
expect(_indicator(tester).value, closeTo(0.6, 0.001));
});
testWidgets('a standing value still animates while busy', (tester) async {
await tester.pumpWidget(
_host(const ChainProgressBar(value: 0.42, busy: true)),
);
await tester.pump(const Duration(milliseconds: 700));
// The highlight is a repeating animation; pumpAndSettle would time
// out precisely because something is still moving. That is the
// property under test: the value stands, the bar does not.
expect(tester.hasRunningAnimations, isTrue);
});
testWidgets('nothing animates once the work is done', (tester) async {
await tester.pumpWidget(
_host(const ChainProgressBar(value: 1.0, busy: false)),
);
await tester.pumpAndSettle();
expect(tester.hasRunningAnimations, isFalse,
reason: 'an idle surface must be still');
});
testWidgets('reduce-motion drops the highlight but keeps the value',
(tester) async {
await tester.pumpWidget(
_host(
const ChainProgressBar(value: 0.5, busy: true),
disableAnimations: true,
),
);
await tester.pump(const Duration(milliseconds: 700));
expect(_indicator(tester).value, closeTo(0.5, 0.001));
});
testWidgets('detail text replaces the percentage when given',
(tester) async {
await tester.pumpWidget(
_host(const ChainProgressBar(
value: 0.3,
stage: 'Herunterladen',
detail: '3,4 von 12,0 MB',
)),
);
await tester.pump(const Duration(milliseconds: 700));
expect(find.text('3,4 von 12,0 MB'), findsOneWidget);
expect(find.text('30 %'), findsNothing);
});
testWidgets('screen readers get stage and percentage', (tester) async {
final handle = tester.ensureSemantics();
await tester.pumpWidget(
_host(const ChainProgressBar(value: 0.25, stage: 'Wird entpackt')),
);
await tester.pump(const Duration(milliseconds: 700));
expect(
tester.getSemantics(find.byType(ChainProgressBar)),
matchesSemantics(label: 'Wird entpackt', value: '25 %'),
);
handle.dispose();
});
}