test(studio): lock sidebar destination Y-stability on rail expansion
Adds a widget test asserting each destination icon keeps its Y position whether the rail is collapsed or expanded — a recurring regression. To drive expansion without a hover gesture (which trips RenderFlex overflow mid-transition), a test-only startSidebarExpanded flag threads StudioApp -> StudioShell -> _Sidebar (controller starts at 1.0, hover disabled); _SidebarItems get stable ValueKeys. analyze clean; new test and the existing smoke test pass. Signed-off-by: flemming-it <stefan.a.flemming@googlemail.com>
This commit is contained in:
parent
d66f46f133
commit
36a8252f67
2 changed files with 103 additions and 5 deletions
|
|
@ -60,11 +60,18 @@ class StudioApp extends StatefulWidget {
|
|||
/// ChainTheme.
|
||||
final String? initialThemePlugin;
|
||||
|
||||
/// Test-only: start the sidebar expanded (no hover). Lets a widget
|
||||
/// test assert destination-icon Y stability across collapsed vs
|
||||
/// expanded without a hover gesture (which trips RenderFlex overflow
|
||||
/// mid-transition). Always false in production.
|
||||
final bool startSidebarExpanded;
|
||||
|
||||
const StudioApp({
|
||||
super.key,
|
||||
required this.initialThemeMode,
|
||||
required this.initialLocale,
|
||||
this.initialThemePlugin,
|
||||
this.startSidebarExpanded = false,
|
||||
});
|
||||
|
||||
/// Lookup helper so descendants can flip the theme without
|
||||
|
|
@ -226,7 +233,9 @@ class StudioAppState extends State<StudioApp> {
|
|||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
localizationsDelegates:
|
||||
AppLocalizations.localizationsDelegates,
|
||||
home: const StudioShell(),
|
||||
home: StudioShell(
|
||||
startSidebarExpanded: widget.startSidebarExpanded,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
@ -237,7 +246,10 @@ class StudioAppState extends State<StudioApp> {
|
|||
}
|
||||
|
||||
class StudioShell extends StatefulWidget {
|
||||
const StudioShell({super.key});
|
||||
/// Test-only: forward to the sidebar so it starts expanded.
|
||||
final bool startSidebarExpanded;
|
||||
|
||||
const StudioShell({super.key, this.startSidebarExpanded = false});
|
||||
|
||||
@override
|
||||
State<StudioShell> createState() => StudioShellState();
|
||||
|
|
@ -523,6 +535,7 @@ class StudioShellState extends State<StudioShell> {
|
|||
endpointLabel: HubService.instance.endpointLabel,
|
||||
activeChannel: _activeChannel,
|
||||
pendingApprovals: _pendingApprovals,
|
||||
forceExpanded: widget.startSidebarExpanded,
|
||||
),
|
||||
Container(width: 1, color: theme.colorScheme.outlineVariant),
|
||||
Expanded(
|
||||
|
|
@ -649,6 +662,11 @@ class _Sidebar extends StatefulWidget {
|
|||
/// without polling the page.
|
||||
final int pendingApprovals;
|
||||
|
||||
/// Test-only: start fully expanded (controller at 1.0) and disable
|
||||
/// hover, so a widget test can measure the expanded layout without a
|
||||
/// hover gesture. Always false in production.
|
||||
final bool forceExpanded;
|
||||
|
||||
const _Sidebar({
|
||||
required this.selectedIndex,
|
||||
required this.onSelect,
|
||||
|
|
@ -657,6 +675,7 @@ class _Sidebar extends StatefulWidget {
|
|||
required this.endpointLabel,
|
||||
required this.activeChannel,
|
||||
this.pendingApprovals = 0,
|
||||
this.forceExpanded = false,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -694,7 +713,7 @@ class _SidebarState extends State<_Sidebar>
|
|||
// the rail feels crisp rather than sluggish.
|
||||
duration: ChainMotion.slow,
|
||||
reverseDuration: ChainMotion.base,
|
||||
value: 0,
|
||||
value: widget.forceExpanded ? 1 : 0,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -740,8 +759,8 @@ class _SidebarState extends State<_Sidebar>
|
|||
final hasChannel =
|
||||
widget.activeChannel != null && widget.activeChannel!.isNotEmpty;
|
||||
return MouseRegion(
|
||||
onEnter: (_) => _ctrl.forward(),
|
||||
onExit: (_) => _ctrl.reverse(),
|
||||
onEnter: widget.forceExpanded ? null : (_) => _ctrl.forward(),
|
||||
onExit: widget.forceExpanded ? null : (_) => _ctrl.reverse(),
|
||||
child: AnimatedBuilder(
|
||||
animation: _ctrl,
|
||||
builder: (context, _) {
|
||||
|
|
@ -837,6 +856,7 @@ class _SidebarState extends State<_Sidebar>
|
|||
children: [
|
||||
for (var i = 0; i < widget.pages.length; i++)
|
||||
_SidebarItem(
|
||||
key: ValueKey('sidebar-item-${widget.pages[i].id}'),
|
||||
page: widget.pages[i],
|
||||
selected: i == widget.selectedIndex,
|
||||
t: t,
|
||||
|
|
@ -1342,6 +1362,7 @@ class _SidebarItem extends StatefulWidget {
|
|||
final int? badge;
|
||||
|
||||
const _SidebarItem({
|
||||
super.key,
|
||||
required this.page,
|
||||
required this.selected,
|
||||
required this.t,
|
||||
|
|
|
|||
77
test/sidebar_test.dart
Normal file
77
test/sidebar_test.dart
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
// Locks the sidebar invariant: destination icons sit at the same Y
|
||||
// whether the rail is collapsed or expanded. The header rows above the
|
||||
// destinations list have FIXED pixel heights (see _SidebarState), so
|
||||
// expanding the rail (a width-only change) must not shift any
|
||||
// destination vertically. This was a recurring regression source; this
|
||||
// test pins it.
|
||||
//
|
||||
// Expansion is driven via StudioApp(startSidebarExpanded: true), NOT a
|
||||
// hover gesture — hover mid-transition trips RenderFlex overflow in
|
||||
// widget tests (see widget_test.dart). With the flag the rail's
|
||||
// animation controller starts at value 1.0 (fully expanded), so no
|
||||
// animation needs to settle.
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:chain_studio/data/hub.dart';
|
||||
import 'package:chain_studio/main.dart';
|
||||
|
||||
const _destinations = <String>[
|
||||
'welcome',
|
||||
'store',
|
||||
'doctor',
|
||||
'flows',
|
||||
'audit',
|
||||
'approvals',
|
||||
'federation',
|
||||
];
|
||||
|
||||
Map<String, double> _destinationYs(WidgetTester tester) {
|
||||
final ys = <String, double>{};
|
||||
for (final id in _destinations) {
|
||||
final finder = find.byKey(ValueKey('sidebar-item-$id'));
|
||||
expect(finder, findsOneWidget, reason: 'destination "$id" must render');
|
||||
ys[id] = tester.getTopLeft(finder).dy;
|
||||
}
|
||||
return ys;
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'sidebar destination Y positions are stable across rail expansion',
|
||||
(tester) async {
|
||||
// Collapsed (default).
|
||||
await tester.pumpWidget(
|
||||
const StudioApp(
|
||||
initialThemeMode: ThemeModeValue.system,
|
||||
initialLocale: Locale('en'),
|
||||
),
|
||||
);
|
||||
// pump (not pumpAndSettle): the app has long-lived timers/animations
|
||||
// that never "settle"; one frame is enough to lay the sidebar out.
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
final collapsed = _destinationYs(tester);
|
||||
|
||||
// Expanded: controller starts at 1.0 via the flag, hover disabled.
|
||||
await tester.pumpWidget(
|
||||
const StudioApp(
|
||||
initialThemeMode: ThemeModeValue.system,
|
||||
initialLocale: Locale('en'),
|
||||
startSidebarExpanded: true,
|
||||
),
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
final expanded = _destinationYs(tester);
|
||||
|
||||
for (final id in _destinations) {
|
||||
expect(
|
||||
expanded[id],
|
||||
closeTo(collapsed[id]!, 0.5),
|
||||
reason: 'destination "$id" Y shifted on expansion '
|
||||
'(${collapsed[id]} -> ${expanded[id]}) — the rows above the '
|
||||
'destinations list must keep fixed heights',
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue