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.
|
/// ChainTheme.
|
||||||
final String? initialThemePlugin;
|
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({
|
const StudioApp({
|
||||||
super.key,
|
super.key,
|
||||||
required this.initialThemeMode,
|
required this.initialThemeMode,
|
||||||
required this.initialLocale,
|
required this.initialLocale,
|
||||||
this.initialThemePlugin,
|
this.initialThemePlugin,
|
||||||
|
this.startSidebarExpanded = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Lookup helper so descendants can flip the theme without
|
/// Lookup helper so descendants can flip the theme without
|
||||||
|
|
@ -226,7 +233,9 @@ class StudioAppState extends State<StudioApp> {
|
||||||
supportedLocales: AppLocalizations.supportedLocales,
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
localizationsDelegates:
|
localizationsDelegates:
|
||||||
AppLocalizations.localizationsDelegates,
|
AppLocalizations.localizationsDelegates,
|
||||||
home: const StudioShell(),
|
home: StudioShell(
|
||||||
|
startSidebarExpanded: widget.startSidebarExpanded,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
@ -237,7 +246,10 @@ class StudioAppState extends State<StudioApp> {
|
||||||
}
|
}
|
||||||
|
|
||||||
class StudioShell extends StatefulWidget {
|
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
|
@override
|
||||||
State<StudioShell> createState() => StudioShellState();
|
State<StudioShell> createState() => StudioShellState();
|
||||||
|
|
@ -523,6 +535,7 @@ class StudioShellState extends State<StudioShell> {
|
||||||
endpointLabel: HubService.instance.endpointLabel,
|
endpointLabel: HubService.instance.endpointLabel,
|
||||||
activeChannel: _activeChannel,
|
activeChannel: _activeChannel,
|
||||||
pendingApprovals: _pendingApprovals,
|
pendingApprovals: _pendingApprovals,
|
||||||
|
forceExpanded: widget.startSidebarExpanded,
|
||||||
),
|
),
|
||||||
Container(width: 1, color: theme.colorScheme.outlineVariant),
|
Container(width: 1, color: theme.colorScheme.outlineVariant),
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|
@ -649,6 +662,11 @@ class _Sidebar extends StatefulWidget {
|
||||||
/// without polling the page.
|
/// without polling the page.
|
||||||
final int pendingApprovals;
|
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({
|
const _Sidebar({
|
||||||
required this.selectedIndex,
|
required this.selectedIndex,
|
||||||
required this.onSelect,
|
required this.onSelect,
|
||||||
|
|
@ -657,6 +675,7 @@ class _Sidebar extends StatefulWidget {
|
||||||
required this.endpointLabel,
|
required this.endpointLabel,
|
||||||
required this.activeChannel,
|
required this.activeChannel,
|
||||||
this.pendingApprovals = 0,
|
this.pendingApprovals = 0,
|
||||||
|
this.forceExpanded = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|
@ -694,7 +713,7 @@ class _SidebarState extends State<_Sidebar>
|
||||||
// the rail feels crisp rather than sluggish.
|
// the rail feels crisp rather than sluggish.
|
||||||
duration: ChainMotion.slow,
|
duration: ChainMotion.slow,
|
||||||
reverseDuration: ChainMotion.base,
|
reverseDuration: ChainMotion.base,
|
||||||
value: 0,
|
value: widget.forceExpanded ? 1 : 0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -740,8 +759,8 @@ class _SidebarState extends State<_Sidebar>
|
||||||
final hasChannel =
|
final hasChannel =
|
||||||
widget.activeChannel != null && widget.activeChannel!.isNotEmpty;
|
widget.activeChannel != null && widget.activeChannel!.isNotEmpty;
|
||||||
return MouseRegion(
|
return MouseRegion(
|
||||||
onEnter: (_) => _ctrl.forward(),
|
onEnter: widget.forceExpanded ? null : (_) => _ctrl.forward(),
|
||||||
onExit: (_) => _ctrl.reverse(),
|
onExit: widget.forceExpanded ? null : (_) => _ctrl.reverse(),
|
||||||
child: AnimatedBuilder(
|
child: AnimatedBuilder(
|
||||||
animation: _ctrl,
|
animation: _ctrl,
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
|
|
@ -837,6 +856,7 @@ class _SidebarState extends State<_Sidebar>
|
||||||
children: [
|
children: [
|
||||||
for (var i = 0; i < widget.pages.length; i++)
|
for (var i = 0; i < widget.pages.length; i++)
|
||||||
_SidebarItem(
|
_SidebarItem(
|
||||||
|
key: ValueKey('sidebar-item-${widget.pages[i].id}'),
|
||||||
page: widget.pages[i],
|
page: widget.pages[i],
|
||||||
selected: i == widget.selectedIndex,
|
selected: i == widget.selectedIndex,
|
||||||
t: t,
|
t: t,
|
||||||
|
|
@ -1342,6 +1362,7 @@ class _SidebarItem extends StatefulWidget {
|
||||||
final int? badge;
|
final int? badge;
|
||||||
|
|
||||||
const _SidebarItem({
|
const _SidebarItem({
|
||||||
|
super.key,
|
||||||
required this.page,
|
required this.page,
|
||||||
required this.selected,
|
required this.selected,
|
||||||
required this.t,
|
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