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:
flemming-it 2026-06-19 21:30:02 +02:00
parent d66f46f133
commit 36a8252f67
2 changed files with 103 additions and 5 deletions

View file

@ -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
View 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',
);
}
},
);
}