From 36a8252f67288150960412cdd923c3adf5008ca8 Mon Sep 17 00:00:00 2001 From: flemming-it Date: Fri, 19 Jun 2026 21:30:02 +0200 Subject: [PATCH] test(studio): lock sidebar destination Y-stability on rail expansion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/main.dart | 31 ++++++++++++++--- test/sidebar_test.dart | 77 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 5 deletions(-) create mode 100644 test/sidebar_test.dart diff --git a/lib/main.dart b/lib/main.dart index e7da38c..75aaa20 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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 { supportedLocales: AppLocalizations.supportedLocales, localizationsDelegates: AppLocalizations.localizationsDelegates, - home: const StudioShell(), + home: StudioShell( + startSidebarExpanded: widget.startSidebarExpanded, + ), ); }, ), @@ -237,7 +246,10 @@ class StudioAppState extends State { } 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 createState() => StudioShellState(); @@ -523,6 +535,7 @@ class StudioShellState extends State { 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, diff --git a/test/sidebar_test.dart b/test/sidebar_test.dart new file mode 100644 index 0000000..15d87e6 --- /dev/null +++ b/test/sidebar_test.dart @@ -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 = [ + 'welcome', + 'store', + 'doctor', + 'flows', + 'audit', + 'approvals', + 'federation', +]; + +Map _destinationYs(WidgetTester tester) { + final ys = {}; + 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', + ); + } + }, + ); +}