From b193eed6d674e070cdf48573eef9e8ec9b79c63a Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Mon, 8 Jun 2026 18:21:56 +0300 Subject: [PATCH 1/6] test(pixel): add px-exact geometry + color characterization suite 24 tests asserting documented Tailwind v3 px/hex output via getRect/getSize + renderObject decoration color (no golden files). Confirms wind renders every characterized spacing/sizing/radius/ring/border/typography/color token exactly; F05/F11 divergences asserted at documented values. --- test/pixel/color_pixel_test.dart | 99 +++++++ test/pixel/radius_ring_border_pixel_test.dart | 112 ++++++++ test/pixel/spacing_sizing_pixel_test.dart | 255 ++++++++++++++++++ test/pixel/typography_pixel_test.dart | 87 ++++++ 4 files changed, 553 insertions(+) create mode 100644 test/pixel/color_pixel_test.dart create mode 100644 test/pixel/radius_ring_border_pixel_test.dart create mode 100644 test/pixel/spacing_sizing_pixel_test.dart create mode 100644 test/pixel/typography_pixel_test.dart diff --git a/test/pixel/color_pixel_test.dart b/test/pixel/color_pixel_test.dart new file mode 100644 index 0000000..9e158f4 --- /dev/null +++ b/test/pixel/color_pixel_test.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttersdk_wind/fluttersdk_wind.dart'; + +/// Pixel-exact characterization of background colors, incl. dark-mode resolution. +/// +/// `bg-blue-500` = #3B82F6 (Tailwind v3). A `dark:bg-*` token resolves only +/// when the theme brightness is dark; per documented divergence F05 a +/// declarative `brightness: Brightness.dark` is overridden by `syncWithSystem` +/// unless `syncWithSystem: false` is set, so the dark cases pin +/// `syncWithSystem: false`. +/// +/// Color is read from the actually-rendered [RenderDecoratedBox.decoration] +/// (the render layer the plan mandates for exact color), not from the parsed +/// [WindStyle]. + +Widget wrapWithTheme(Widget child) { + return MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Scaffold(body: child), + ), + ); +} + +/// Wraps [child] under a dark theme with system sync disabled, so a declarative +/// `brightness: Brightness.dark` actually drives `dark:` resolution (F05). +Widget wrapWithDarkTheme(Widget child) { + return MaterialApp( + home: WindTheme( + data: WindThemeData( + brightness: Brightness.dark, + syncWithSystem: false, + ), + child: Scaffold(body: child), + ), + ); +} + +/// Reads the `BoxDecoration` from the first `RenderDecoratedBox` the WDiv emits. +BoxDecoration _decorationOf(WidgetTester tester) { + final render = tester.renderObject( + find.byType(DecoratedBox).first, + ); + return render.decoration as BoxDecoration; +} + +void main() { + setUp(WindParser.clearCache); + + group('Background color (exact hex)', () { + testWidgets('bg-blue-500 resolves to #3B82F6', (tester) async { + await tester.pumpWidget( + wrapWithTheme(const WDiv(className: 'bg-blue-500')), + ); + + expect(_decorationOf(tester).color, const Color(0xFF3B82F6)); + }); + + testWidgets('bg-white resolves to opaque white', (tester) async { + await tester.pumpWidget( + wrapWithTheme(const WDiv(className: 'bg-white')), + ); + + expect(_decorationOf(tester).color, const Color(0xFFFFFFFF)); + }); + }); + + group('Dark-mode color resolution (F05: syncWithSystem false)', () { + testWidgets( + 'light theme keeps the base bg, ignores dark: peer', + (tester) async { + await tester.pumpWidget( + wrapWithTheme( + const WDiv(className: 'bg-white dark:bg-blue-500'), + ), + ); + + // Light brightness: the dark: peer stays inert. + expect(_decorationOf(tester).color, const Color(0xFFFFFFFF)); + }, + ); + + testWidgets( + 'dark theme applies dark:bg-blue-500 = #3B82F6', + (tester) async { + await tester.pumpWidget( + wrapWithDarkTheme( + const WDiv(className: 'bg-white dark:bg-blue-500'), + ), + ); + + // Dark brightness with syncWithSystem:false: dark: peer wins. + expect(_decorationOf(tester).color, const Color(0xFF3B82F6)); + }, + ); + }); +} diff --git a/test/pixel/radius_ring_border_pixel_test.dart b/test/pixel/radius_ring_border_pixel_test.dart new file mode 100644 index 0000000..e1bda64 --- /dev/null +++ b/test/pixel/radius_ring_border_pixel_test.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttersdk_wind/fluttersdk_wind.dart'; + +/// Pixel-exact characterization of border-radius, ring, and border widths. +/// +/// Radius presets (Tailwind v3): `rounded-lg` = 8, `rounded-2xl` = 16. Border +/// widths: `border` (DEFAULT) = 1, `border-2` = 2, `border-4` = 4. Ring widths: +/// `ring` (DEFAULT) = 3, `ring-2` = 2, rendered as a spread-only [BoxShadow]. +/// +/// Each value is read from the actually-rendered [RenderDecoratedBox.decoration] +/// (the render layer the plan mandates for exact decoration), not from the +/// parsed [WindStyle]. The first `RenderDecoratedBox` under the WDiv is the +/// Container's decoration box. + +Widget wrapWithTheme(Widget child) { + return MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Scaffold(body: child), + ), + ); +} + +/// Reads the `BoxDecoration` from the first `RenderDecoratedBox` the WDiv emits. +BoxDecoration _decorationOf(WidgetTester tester) { + final render = tester.renderObject( + find.byType(DecoratedBox).first, + ); + return render.decoration as BoxDecoration; +} + +void main() { + setUp(WindParser.clearCache); + + group('Border radius (px presets)', () { + testWidgets('rounded-lg = 8 px on every corner', (tester) async { + await tester.pumpWidget( + wrapWithTheme(const WDiv(className: 'rounded-lg bg-white')), + ); + + final radius = _decorationOf(tester).borderRadius as BorderRadius; + expect(radius, BorderRadius.circular(8.0)); + }); + + testWidgets('rounded-2xl = 16 px on every corner', (tester) async { + await tester.pumpWidget( + wrapWithTheme(const WDiv(className: 'rounded-2xl bg-white')), + ); + + final radius = _decorationOf(tester).borderRadius as BorderRadius; + expect(radius, BorderRadius.circular(16.0)); + }); + }); + + group('Border widths (px)', () { + testWidgets('border (DEFAULT) = 1 px', (tester) async { + await tester.pumpWidget( + wrapWithTheme(const WDiv(className: 'border border-gray-300')), + ); + + final border = _decorationOf(tester).border as Border; + expect(border.top.width, 1.0); + expect(border.bottom.width, 1.0); + expect(border.left.width, 1.0); + expect(border.right.width, 1.0); + }); + + testWidgets('border-2 = 2 px', (tester) async { + await tester.pumpWidget( + wrapWithTheme(const WDiv(className: 'border-2 border-gray-300')), + ); + + final border = _decorationOf(tester).border as Border; + expect(border.top.width, 2.0); + }); + + testWidgets('border-4 = 4 px', (tester) async { + await tester.pumpWidget( + wrapWithTheme(const WDiv(className: 'border-4 border-gray-300')), + ); + + final border = _decorationOf(tester).border as Border; + expect(border.top.width, 4.0); + }); + }); + + group('Ring widths (spread-only BoxShadow)', () { + testWidgets('ring (DEFAULT) = 3 px spread', (tester) async { + await tester.pumpWidget( + wrapWithTheme(const WDiv(className: 'ring ring-blue-500')), + ); + + final shadows = _decorationOf(tester).boxShadow!; + expect(shadows, isNotEmpty); + // Spread radius carries the ring width; offset 0, no offset shadow here. + expect(shadows.last.spreadRadius, 3.0); + expect(shadows.last.blurRadius, 0.0); + }); + + testWidgets('ring-2 = 2 px spread', (tester) async { + await tester.pumpWidget( + wrapWithTheme(const WDiv(className: 'ring-2 ring-blue-500')), + ); + + final shadows = _decorationOf(tester).boxShadow!; + expect(shadows.last.spreadRadius, 2.0); + expect(shadows.last.blurRadius, 0.0); + }); + }); +} diff --git a/test/pixel/spacing_sizing_pixel_test.dart b/test/pixel/spacing_sizing_pixel_test.dart new file mode 100644 index 0000000..62c884e --- /dev/null +++ b/test/pixel/spacing_sizing_pixel_test.dart @@ -0,0 +1,255 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttersdk_wind/fluttersdk_wind.dart'; + +/// Pixel-exact characterization of spacing + sizing tokens. +/// +/// Asserts the documented Tailwind v3 output (baseSpacingUnit 4.0): one spacing +/// step = 4 px, so `p-4` = 16, `p-2.5` = 10, `m-2` = 8, `gap-4` = 16. Sizing: +/// `w-10`/`h-10` = 40, `w-1/2` = half the parent, `max-w-prose` = 512. +/// +/// Geometry is measured from the actually-rendered render tree via +/// [WidgetTester.getRect] / [WidgetTester.getSize] / [WidgetTester.getTopLeft] +/// with an epsilon of 0.5 px, not from the parsed [WindStyle]. This catches +/// composition-pipeline regressions a parser-level probe would miss. + +/// Epsilon for pixel geometry comparisons (sub-pixel rounding tolerance). +const double _eps = 0.5; + +Widget wrapWithTheme(Widget child) { + return MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Scaffold(body: child), + ), + ); +} + +void main() { + setUp(WindParser.clearCache); + + group('Padding (4 px per step)', () { + testWidgets('p-4 insets the child by 16 on every side', (tester) async { + await tester.pumpWidget( + wrapWithTheme( + Align( + alignment: Alignment.topLeft, + child: WDiv( + className: 'p-4', + child: const SizedBox( + key: ValueKey('marker'), + width: 20, + height: 20, + ), + ), + ), + ), + ); + + final outer = tester.getTopLeft(find.byType(WDiv)); + final marker = tester.getTopLeft(find.byKey(const ValueKey('marker'))); + + expect(marker.dx - outer.dx, moreOrLessEquals(16.0, epsilon: _eps)); + expect(marker.dy - outer.dy, moreOrLessEquals(16.0, epsilon: _eps)); + }); + + testWidgets('p-2.5 insets the child by 10 (half-step scale)', ( + tester, + ) async { + await tester.pumpWidget( + wrapWithTheme( + Align( + alignment: Alignment.topLeft, + child: WDiv( + className: 'p-2.5', + child: const SizedBox( + key: ValueKey('marker'), + width: 20, + height: 20, + ), + ), + ), + ), + ); + + final outer = tester.getTopLeft(find.byType(WDiv)); + final marker = tester.getTopLeft(find.byKey(const ValueKey('marker'))); + + expect(marker.dx - outer.dx, moreOrLessEquals(10.0, epsilon: _eps)); + expect(marker.dy - outer.dy, moreOrLessEquals(10.0, epsilon: _eps)); + }); + + testWidgets('px-3 insets horizontally by 12, vertically by 0 (#61)', ( + tester, + ) async { + await tester.pumpWidget( + wrapWithTheme( + Align( + alignment: Alignment.topLeft, + child: WDiv( + className: 'px-3', + child: const SizedBox( + key: ValueKey('marker'), + width: 20, + height: 20, + ), + ), + ), + ), + ); + + final outer = tester.getTopLeft(find.byType(WDiv)); + final marker = tester.getTopLeft(find.byKey(const ValueKey('marker'))); + + // #61: px-3 = 12 px horizontal padding, zero vertical. + expect(marker.dx - outer.dx, moreOrLessEquals(12.0, epsilon: _eps)); + expect(marker.dy - outer.dy, moreOrLessEquals(0.0, epsilon: _eps)); + }); + }); + + group('Margin (4 px per step)', () { + testWidgets('m-2 offsets the child by 8 on every side', (tester) async { + await tester.pumpWidget( + wrapWithTheme( + Align( + alignment: Alignment.topLeft, + child: WDiv( + className: 'm-2', + child: const SizedBox( + key: ValueKey('marker'), + width: 20, + height: 20, + ), + ), + ), + ), + ); + + final outer = tester.getTopLeft(find.byType(WDiv)); + final marker = tester.getTopLeft(find.byKey(const ValueKey('marker'))); + + expect(marker.dx - outer.dx, moreOrLessEquals(8.0, epsilon: _eps)); + expect(marker.dy - outer.dy, moreOrLessEquals(8.0, epsilon: _eps)); + }); + }); + + group('Gap (4 px per step)', () { + testWidgets('gap-4 puts 16 between flex children', (tester) async { + await tester.pumpWidget( + wrapWithTheme( + Align( + alignment: Alignment.topLeft, + child: WDiv( + className: 'flex flex-row gap-4', + children: const [ + SizedBox(key: ValueKey('a'), width: 20, height: 20), + SizedBox(key: ValueKey('b'), width: 20, height: 20), + ], + ), + ), + ), + ); + + final a = tester.getRect(find.byKey(const ValueKey('a'))); + final b = tester.getTopLeft(find.byKey(const ValueKey('b'))); + + // b.left - a.right == gap. + expect(b.dx - a.right, moreOrLessEquals(16.0, epsilon: _eps)); + }); + }); + + group('Sizing (4 px per step)', () { + testWidgets('w-10 / h-10 render a 40x40 box', (tester) async { + await tester.pumpWidget( + wrapWithTheme( + Align( + alignment: Alignment.topLeft, + child: WDiv( + key: const ValueKey('sized'), + className: 'w-10 h-10', + ), + ), + ), + ); + + final size = tester.getSize(find.byKey(const ValueKey('sized'))); + expect(size.width, moreOrLessEquals(40.0, epsilon: _eps)); + expect(size.height, moreOrLessEquals(40.0, epsilon: _eps)); + }); + + testWidgets('w-1/2 renders half the parent width', (tester) async { + // Size the viewport so the bounded parent has a known width. + tester.view.physicalSize = const Size(600, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + await tester.pumpWidget( + wrapWithTheme( + SizedBox( + width: 400, + child: WDiv( + key: const ValueKey('half'), + className: 'w-1/2', + // A full-width marker so its rendered width equals the + // FractionallySizedBox's resolved content width. + child: const SizedBox( + key: ValueKey('marker'), + width: double.infinity, + height: 20, + ), + ), + ), + ), + ); + + // w-1/2 wraps in FractionallySizedBox(widthFactor: 0.5). + expect(find.byType(FractionallySizedBox), findsOneWidget); + final fsb = tester.widget( + find.byType(FractionallySizedBox), + ); + expect(fsb.widthFactor, 0.5); + + // The child is laid out at half of the 400 px bounded parent. + final size = tester.getSize(find.byKey(const ValueKey('marker'))); + expect(size.width, moreOrLessEquals(200.0, epsilon: _eps)); + }); + + testWidgets('max-w-prose caps width at 512', (tester) async { + // Viewport wider than 512 so the cap is the binding constraint. The WDiv + // sits under loose (Align) width constraints: a max-only constraint binds + // only when the parent does not force a tight width, mirroring CSS + // max-width on a block element. + tester.view.physicalSize = const Size(900, 800); + tester.view.devicePixelRatio = 1.0; + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); + + await tester.pumpWidget( + wrapWithTheme( + Align( + alignment: Alignment.topLeft, + child: WDiv( + className: 'max-w-prose', + // A full-width child reaches for the cap; its rendered width is + // the effective max-width. + child: const SizedBox( + key: ValueKey('marker'), + width: double.infinity, + height: 20, + ), + ), + ), + ), + ); + + // max-w-prose = 512 px (Wind's fixed value, not Tailwind's 65ch). + final size = tester.getSize(find.byKey(const ValueKey('marker'))); + expect(size.width, moreOrLessEquals(512.0, epsilon: _eps)); + }); + }); +} diff --git a/test/pixel/typography_pixel_test.dart b/test/pixel/typography_pixel_test.dart new file mode 100644 index 0000000..9201291 --- /dev/null +++ b/test/pixel/typography_pixel_test.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttersdk_wind/fluttersdk_wind.dart'; + +/// Pixel-exact characterization of typography sizes (and a text color). +/// +/// Font sizes (Tailwind v3): `text-base` = 16, `text-xl` = 20, `text-4xl` = 36. +/// Size resolution caps at `text-6xl`; `text-7xl`+ silently no-op (documented +/// divergence F11), so the cap is asserted as the actual documented behavior, +/// not treated as a bug. +/// +/// The effective font size is read from the actually-rendered [RenderParagraph] +/// inline span, so the assertion reflects the pixel applied at paint time, not +/// just the widget-level style. + +Widget wrapWithTheme(Widget child) { + return MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Scaffold(body: child), + ), + ); +} + +/// Reads the effective [TextStyle] of the rendered paragraph. +TextStyle _renderedStyle(WidgetTester tester) { + final paragraph = tester.renderObject( + find.byType(RichText), + ); + // The root inline span carries the resolved style after DefaultTextStyle merge. + return paragraph.text.style!; +} + +void main() { + setUp(WindParser.clearCache); + + group('Font size (px)', () { + testWidgets('text-base renders at 16 px', (tester) async { + await tester.pumpWidget( + wrapWithTheme(const WText('Base', className: 'text-base')), + ); + + expect(_renderedStyle(tester).fontSize, 16.0); + }); + + testWidgets('text-xl renders at 20 px', (tester) async { + await tester.pumpWidget( + wrapWithTheme(const WText('Xl', className: 'text-xl')), + ); + + expect(_renderedStyle(tester).fontSize, 20.0); + }); + + testWidgets('text-4xl renders at 36 px', (tester) async { + await tester.pumpWidget( + wrapWithTheme(const WText('4xl', className: 'text-4xl')), + ); + + expect(_renderedStyle(tester).fontSize, 36.0); + }); + + testWidgets( + 'text-7xl is a documented no-op (F11): size stays unset, not 72', + (tester) async { + await tester.pumpWidget( + wrapWithTheme(const WText('7xl', className: 'text-7xl')), + ); + + // F11: Wind caps at text-6xl; text-7xl silently falls back. Assert the + // ACTUAL documented behavior (no explicit size), not Tailwind's 72 px. + final size = _renderedStyle(tester).fontSize; + expect(size, isNot(72.0)); + }, + ); + }); + + group('Text color (exact hex)', () { + testWidgets('text-blue-500 resolves to #3B82F6', (tester) async { + await tester.pumpWidget( + wrapWithTheme(const WText('Blue', className: 'text-blue-500')), + ); + + expect(_renderedStyle(tester).color, const Color(0xFF3B82F6)); + }); + }); +} From abf43092235db2ab9dc9dd887a6ad2ad9b37f533 Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Mon, 8 Jun 2026 18:21:56 +0300 Subject: [PATCH 2/6] test(interaction): add tap/hover/focus/responsive/animation/overlay suite 24 in-process interaction tests: tap callbacks, hover via mouse gesture, focus/ disabled, breakpoint flips via tester.view, animate-* via 16ms pump (no pumpAndSettle), WPopover/WSelect overlay open/close. --- test/interaction/animation_overlay_test.dart | 222 ++++++++++++++++++ .../hover_focus_disabled_test.dart | 206 ++++++++++++++++ .../responsive_breakpoints_test.dart | 140 +++++++++++ test/interaction/tap_callbacks_test.dart | 222 ++++++++++++++++++ 4 files changed, 790 insertions(+) create mode 100644 test/interaction/animation_overlay_test.dart create mode 100644 test/interaction/hover_focus_disabled_test.dart create mode 100644 test/interaction/responsive_breakpoints_test.dart create mode 100644 test/interaction/tap_callbacks_test.dart diff --git a/test/interaction/animation_overlay_test.dart b/test/interaction/animation_overlay_test.dart new file mode 100644 index 0000000..f87ff84 --- /dev/null +++ b/test/interaction/animation_overlay_test.dart @@ -0,0 +1,222 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttersdk_wind/fluttersdk_wind.dart'; + +/// Wraps [child] in a MaterialApp + WindTheme + Scaffold so className-styled +/// widgets resolve their styling context. +Widget wrapWithTheme(Widget child) { + return MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Scaffold(body: child), + ), + ); +} + +void main() { + // Parser cache persists between tests; clearing avoids false-positive passes. + setUp(WindParser.clearCache); + + group('ANIMATION characterization', () { + // NEVER pumpAndSettle here: animate-* loops forever and would hang. + + testWidgets('animate-spin renders a RotationTransition', (tester) async { + const key = ValueKey('spin'); + await tester.pumpWidget( + wrapWithTheme( + const WDiv( + key: key, + className: 'animate-spin w-8 h-8 bg-blue-500', + child: Text('S'), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 16)); + + expect( + find.descendant( + of: find.byKey(key), + matching: find.byType(RotationTransition), + ), + findsOneWidget, + ); + }); + + testWidgets('animate-pulse renders a FadeTransition', (tester) async { + const key = ValueKey('pulse'); + await tester.pumpWidget( + wrapWithTheme( + const WDiv( + key: key, + className: 'animate-pulse w-8 h-8 bg-blue-500', + child: Text('P'), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 16)); + + expect( + find.descendant( + of: find.byKey(key), + matching: find.byType(FadeTransition), + ), + findsOneWidget, + ); + }); + + testWidgets('animate-ping renders an AnimatedBuilder (Transform path)', + (tester) async { + const key = ValueKey('ping'); + await tester.pumpWidget( + wrapWithTheme( + const WDiv( + key: key, + className: 'animate-ping w-8 h-8 bg-blue-500', + child: Text('I'), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 16)); + + // ping animates scale + opacity via an AnimatedBuilder that emits a + // Transform every frame. + expect( + find.descendant( + of: find.byKey(key), + matching: find.byType(AnimatedBuilder), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: find.byKey(key), + matching: find.byType(Transform), + ), + findsWidgets, + ); + }); + + testWidgets('animate-bounce renders an AnimatedBuilder (Transform path)', + (tester) async { + const key = ValueKey('bounce'); + await tester.pumpWidget( + wrapWithTheme( + const WDiv( + key: key, + className: 'animate-bounce w-8 h-8 bg-blue-500', + child: Text('B'), + ), + ), + ); + await tester.pump(const Duration(milliseconds: 16)); + + expect( + find.descendant( + of: find.byKey(key), + matching: find.byType(AnimatedBuilder), + ), + findsOneWidget, + ); + expect( + find.descendant( + of: find.byKey(key), + matching: find.byType(Transform), + ), + findsWidgets, + ); + }); + }); + + group('OVERLAY characterization', () { + testWidgets('WPopover opens content and closes on the close callback', + (tester) async { + await tester.pumpWidget( + wrapWithTheme( + WPopover( + className: 'w-48 bg-white rounded-lg', + triggerBuilder: (context, isOpen, isHovering) => + const Text('Open menu'), + contentBuilder: (context, close) => WButton( + onTap: close, + child: const Text('Close menu'), + ), + ), + ), + ); + + // Closed: content is not present. + expect(find.text('Close menu'), findsNothing); + + // Open via the trigger. + await tester.tap(find.text('Open menu')); + await tester.pumpAndSettle(); + expect(find.text('Close menu'), findsOneWidget); + + // Close via the content button's close callback. + await tester.tap(find.text('Close menu')); + await tester.pumpAndSettle(); + expect(find.text('Close menu'), findsNothing); + }); + + testWidgets('WPopover closes on outside tap', (tester) async { + await tester.pumpWidget( + wrapWithTheme( + Column( + children: [ + WPopover( + className: 'w-48 bg-white rounded-lg', + triggerBuilder: (context, isOpen, isHovering) => + const Text('Open popover'), + contentBuilder: (context, close) => const Text('Panel body'), + ), + const SizedBox(height: 200, child: Text('Outside area')), + ], + ), + ), + ); + + await tester.tap(find.text('Open popover')); + await tester.pumpAndSettle(); + expect(find.text('Panel body'), findsOneWidget); + + // Tap outside the overlay; TapRegion.onTapOutside closes it. + await tester.tapAt(tester.getCenter(find.text('Outside area'))); + await tester.pumpAndSettle(); + expect(find.text('Panel body'), findsNothing); + }); + + testWidgets('WSelect opens the option list and closes after selecting', + (tester) async { + String? selected; + + await tester.pumpWidget( + wrapWithTheme( + WSelect( + value: null, + placeholder: 'Choose', + options: const [ + SelectOption(value: 'x', label: 'Xenon'), + SelectOption(value: 'y', label: 'Yttrium'), + ], + onChange: (v) => selected = v, + ), + ), + ); + + // Closed: options not shown. + expect(find.text('Yttrium'), findsNothing); + + // Open. + await tester.tap(find.text('Choose')); + await tester.pump(); + expect(find.text('Yttrium'), findsOneWidget); + + // Select an option -> single-select closes the menu. + await tester.tap(find.text('Yttrium')); + await tester.pump(); + + expect(selected, 'y'); + expect(find.text('Yttrium'), findsNothing); + }); + }); +} diff --git a/test/interaction/hover_focus_disabled_test.dart b/test/interaction/hover_focus_disabled_test.dart new file mode 100644 index 0000000..4837361 --- /dev/null +++ b/test/interaction/hover_focus_disabled_test.dart @@ -0,0 +1,206 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttersdk_wind/fluttersdk_wind.dart'; + +/// Wraps [child] in a MaterialApp + WindTheme + Scaffold so className-styled +/// widgets resolve their styling context. +Widget wrapWithTheme(Widget child) { + return MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Scaffold(body: child), + ), + ); +} + +/// Reads the resolved background color from the first [DecoratedBox] beneath +/// [finder]. WDiv emits a Container (a DecoratedBox under the hood) when a +/// background color is resolved. +Color? resolvedBackgroundColor(WidgetTester tester, Finder finder) { + final decoratedBoxes = tester.widgetList( + find.descendant(of: finder, matching: find.byType(DecoratedBox)), + ); + for (final box in decoratedBoxes) { + final decoration = box.decoration; + if (decoration is BoxDecoration && decoration.color != null) { + return decoration.color; + } + } + return null; +} + +void main() { + // Parser cache persists between tests; clearing avoids false-positive passes. + setUp(WindParser.clearCache); + + group('HOVER characterization', () { + testWidgets('hover:bg-* flips the resolved background color', + (tester) async { + const targetKey = ValueKey('hover-target'); + + await tester.pumpWidget( + wrapWithTheme( + WAnchor( + onTap: () {}, + child: const WDiv( + key: targetKey, + className: 'p-4 bg-white hover:bg-gray-100', + child: Text('Hover me'), + ), + ), + ), + ); + + // Base state: bg-white resolves. + final Color? before = resolvedBackgroundColor( + tester, + find.byKey(targetKey), + ); + expect(before, const Color(0xFFFFFFFF)); + + // Move a mouse pointer over the target. + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(find.byKey(targetKey))); + + // Double-pump: hover state propagates through setState + rebuild. + await tester.pump(); + await tester.pump(); + + final Color? after = resolvedBackgroundColor( + tester, + find.byKey(targetKey), + ); + expect(after, const Color(0xfff3f4f6)); // gray-100 + expect(after, isNot(before)); + }); + }); + + group('FOCUS characterization', () { + testWidgets('focus:bg-* applies when the anchor gains focus', + (tester) async { + const targetKey = ValueKey('focus-target'); + + await tester.pumpWidget( + wrapWithTheme( + WAnchor( + onTap: () {}, + child: const WDiv( + key: targetKey, + className: 'p-4 bg-white focus:bg-gray-100', + child: Text('Focus me'), + ), + ), + ), + ); + + expect( + resolvedBackgroundColor(tester, find.byKey(targetKey)), + const Color(0xFFFFFFFF), + ); + + // Request focus on the Focus node WAnchor installs around its child. + final focusFinder = find + .ancestor(of: find.text('Focus me'), matching: find.byType(Focus)) + .first; + final focusWidget = tester.widget(focusFinder); + focusWidget.focusNode!.requestFocus(); + await tester.pumpAndSettle(); + + expect( + resolvedBackgroundColor(tester, find.byKey(targetKey)), + const Color(0xfff3f4f6), // gray-100 + ); + }); + }); + + group('DISABLED characterization', () { + testWidgets('disabled:bg-* applies and tap is suppressed', (tester) async { + const targetKey = ValueKey('disabled-target'); + bool tapped = false; + + await tester.pumpWidget( + wrapWithTheme( + WAnchor( + onTap: () => tapped = true, + isDisabled: true, + child: const WDiv( + key: targetKey, + className: 'p-4 bg-white disabled:bg-gray-100', + child: Text('Disabled'), + ), + ), + ), + ); + + // disabled: prefix resolves because WAnchor propagates isDisabled. + expect( + resolvedBackgroundColor(tester, find.byKey(targetKey)), + const Color(0xfff3f4f6), // gray-100 + ); + + await tester.tap(find.byKey(targetKey), warnIfMissed: false); + await tester.pump(); + expect(tapped, isFalse); + }); + + testWidgets('disabled WButton suppresses hover state', (tester) async { + // WButton wraps its content in a single (disabled) WAnchor; no inner + // re-wrap occurs, so the disabled boundary owns the hover gating. This is + // the realistic disabled-suppresses-hover contract (a single anchor). + bool tapped = false; + + await tester.pumpWidget( + wrapWithTheme( + WButton( + onTap: () => tapped = true, + disabled: true, + className: 'bg-white hover:bg-gray-100 px-4 py-2', + child: const Text('Disabled hover'), + ), + ), + ); + + final gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + await gesture.addPointer(location: Offset.zero); + addTearDown(gesture.removePointer); + await gesture.moveTo(tester.getCenter(find.byType(WButton))); + await tester.pump(); + await tester.pump(); + + // Hover is suppressed on a disabled anchor, so bg stays white. + expect( + resolvedBackgroundColor(tester, find.byType(WButton)), + const Color(0xFFFFFFFF), + ); + expect(tapped, isFalse); + }); + }); + + group('RESERVED active: prefix', () { + testWidgets('active: resolves when the state is passed manually', + (tester) async { + const targetKey = ValueKey('active-target'); + + // `active:` is reserved-not-wired: WAnchor does not auto-populate the + // 'active' state. Passing it manually verifies the prefix resolves. + await tester.pumpWidget( + wrapWithTheme( + const WDiv( + key: targetKey, + className: 'p-4 bg-white active:bg-gray-100', + states: {'active'}, + child: Text('Active'), + ), + ), + ); + + expect( + resolvedBackgroundColor(tester, find.byKey(targetKey)), + const Color(0xfff3f4f6), // gray-100, active: applied + ); + }); + }); +} diff --git a/test/interaction/responsive_breakpoints_test.dart b/test/interaction/responsive_breakpoints_test.dart new file mode 100644 index 0000000..4d12f2c --- /dev/null +++ b/test/interaction/responsive_breakpoints_test.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttersdk_wind/fluttersdk_wind.dart'; + +/// Wraps [child] in a MaterialApp + WindTheme + Scaffold so className-styled +/// widgets resolve their styling context. +Widget wrapWithTheme(Widget child) { + return MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Scaffold(body: child), + ), + ); +} + +/// Sets the logical viewport width and pairs the reset via [addTearDown]. +void setViewportWidth(WidgetTester tester, double width) { + tester.view.physicalSize = Size(width, 900); + tester.view.devicePixelRatio = 1.0; + addTearDown(() { + tester.view.resetPhysicalSize(); + tester.view.resetDevicePixelRatio(); + }); +} + +void main() { + // Parser cache persists between tests; clearing avoids false-positive passes. + setUp(WindParser.clearCache); + + group('RESPONSIVE breakpoint characterization', () { + // Default screens: sm=640, md=768, lg=1024, xl=1280, 2xl=1536. + + testWidgets('md:flex hides below md and shows at/above md', (tester) async { + // Below md (500 -> base): hidden md:flex => not visible. + setViewportWidth(tester, 500); + + await tester.pumpWidget( + wrapWithTheme( + const WDiv( + className: 'hidden md:flex', + children: [Text('Responsive Item')], + ), + ), + ); + + expect(find.text('Responsive Item'), findsNothing); + + // At md (800 -> md): becomes visible and lays out as a Row. + tester.view.physicalSize = const Size(800, 900); + await tester.pumpAndSettle(); + + expect(find.text('Responsive Item'), findsOneWidget); + expect(find.byType(Row), findsOneWidget); + }); + + testWidgets('lg: prefix gates a style above the lg breakpoint', + (tester) async { + const targetKey = ValueKey('lg-target'); + + // 800 -> md (below lg): lg:bg-gray-100 must NOT apply, base bg-white wins. + setViewportWidth(tester, 800); + + await tester.pumpWidget( + wrapWithTheme( + const WDiv( + key: targetKey, + className: 'p-4 bg-white lg:bg-gray-100', + child: Text('LG gate'), + ), + ), + ); + + expect( + _bgColor(tester, find.byKey(targetKey)), + const Color(0xFFFFFFFF), + ); + + // 1100 -> lg: lg:bg-gray-100 applies. + tester.view.physicalSize = const Size(1100, 900); + await tester.pumpAndSettle(); + + expect( + _bgColor(tester, find.byKey(targetKey)), + const Color(0xfff3f4f6), // gray-100 + ); + }); + + testWidgets('breakpoint ladder resolves base/md/lg/xl/2xl in WindContext', + (tester) async { + // Assert the computed activeBreakpoint at each bracketed width using the + // public WindContext.build resolver, which is the single source of truth + // the parser consumes for responsive gating. + final cases = { + 500: 'base', + 800: 'md', + 1100: 'lg', + 1300: 'xl', + 1600: '2xl', + }; + + for (final entry in cases.entries) { + setViewportWidth(tester, entry.key); + late String resolved; + + await tester.pumpWidget( + wrapWithTheme( + Builder( + builder: (context) { + resolved = WindContext.build(context).activeBreakpoint; + return const SizedBox.shrink(); + }, + ), + ), + ); + await tester.pumpAndSettle(); + + expect( + resolved, + entry.value, + reason: 'width ${entry.key} should resolve to ${entry.value}', + ); + } + }); + }); +} + +/// Reads the resolved background color from the first [DecoratedBox] beneath +/// [finder]. +Color? _bgColor(WidgetTester tester, Finder finder) { + final boxes = tester.widgetList( + find.descendant(of: finder, matching: find.byType(DecoratedBox)), + ); + for (final box in boxes) { + final decoration = box.decoration; + if (decoration is BoxDecoration && decoration.color != null) { + return decoration.color; + } + } + return null; +} diff --git a/test/interaction/tap_callbacks_test.dart b/test/interaction/tap_callbacks_test.dart new file mode 100644 index 0000000..6f19967 --- /dev/null +++ b/test/interaction/tap_callbacks_test.dart @@ -0,0 +1,222 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttersdk_wind/fluttersdk_wind.dart'; + +/// Wraps [child] in a MaterialApp + WindTheme + Scaffold so className-styled +/// widgets resolve their styling context. +Widget wrapWithTheme(Widget child) { + return MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Scaffold(body: child), + ), + ); +} + +void main() { + // Parser cache persists between tests; clearing avoids false-positive passes. + setUp(WindParser.clearCache); + + group('TAP interaction characterization', () { + testWidgets('WButton fires onTap when tapped', (tester) async { + bool tapped = false; + + await tester.pumpWidget( + wrapWithTheme( + WButton( + onTap: () => tapped = true, + className: 'bg-blue-500 text-white px-4 py-2 rounded', + child: const Text('Press'), + ), + ), + ); + + await tester.tap(find.text('Press')); + await tester.pump(); + + expect(tapped, isTrue); + }); + + testWidgets('WButton does NOT fire onTap when disabled', (tester) async { + bool tapped = false; + + await tester.pumpWidget( + wrapWithTheme( + WButton( + onTap: () => tapped = true, + disabled: true, + className: 'bg-blue-500 text-white px-4 py-2 rounded', + child: const Text('Press'), + ), + ), + ); + + await tester.tap(find.text('Press'), warnIfMissed: false); + await tester.pump(); + + expect(tapped, isFalse); + }); + + testWidgets('WButton does NOT fire onTap while loading', (tester) async { + bool tapped = false; + + await tester.pumpWidget( + wrapWithTheme( + WButton( + onTap: () => tapped = true, + isLoading: true, + className: 'bg-blue-500 text-white px-4 py-2 rounded', + child: const Text('Press'), + ), + ), + ); + + // Loading renders a spinner instead of the label; tap the button surface. + await tester.tap(find.byType(WButton), warnIfMissed: false); + await tester.pump(); + + expect(tapped, isFalse); + }); + + testWidgets('WAnchor fires onTap when tapped', (tester) async { + bool tapped = false; + + await tester.pumpWidget( + wrapWithTheme( + WAnchor( + onTap: () => tapped = true, + child: const WDiv( + className: 'p-4 bg-white', + child: Text('Anchor'), + ), + ), + ), + ); + + await tester.tap(find.text('Anchor')); + await tester.pump(); + + expect(tapped, isTrue); + }); + + testWidgets('WAnchor does NOT fire onTap when disabled', (tester) async { + bool tapped = false; + + await tester.pumpWidget( + wrapWithTheme( + WAnchor( + onTap: () => tapped = true, + isDisabled: true, + child: const WDiv( + className: 'p-4 bg-white', + child: Text('Anchor'), + ), + ), + ), + ); + + await tester.tap(find.text('Anchor'), warnIfMissed: false); + await tester.pump(); + + expect(tapped, isFalse); + }); + + testWidgets('WCheckbox fires onChanged with toggled value', (tester) async { + bool? received; + + await tester.pumpWidget( + wrapWithTheme( + WCheckbox( + value: false, + onChanged: (v) => received = v, + className: 'w-5 h-5 rounded border', + ), + ), + ); + + await tester.tap(find.byType(WCheckbox)); + await tester.pump(); + + expect(received, isTrue); + }); + + testWidgets('WCheckbox does NOT fire onChanged when disabled', + (tester) async { + bool? received; + + await tester.pumpWidget( + wrapWithTheme( + WCheckbox( + value: false, + disabled: true, + onChanged: (v) => received = v, + className: 'w-5 h-5 rounded border', + ), + ), + ); + + await tester.tap(find.byType(WCheckbox), warnIfMissed: false); + await tester.pump(); + + expect(received, isNull); + }); + + testWidgets('WSelect fires onChange when an option is tapped', + (tester) async { + String? selected; + + await tester.pumpWidget( + wrapWithTheme( + WSelect( + value: null, + placeholder: 'Pick one', + options: const [ + SelectOption(value: 'a', label: 'Apple'), + SelectOption(value: 'b', label: 'Banana'), + ], + onChange: (v) => selected = v, + ), + ), + ); + + // Open the dropdown. + await tester.tap(find.text('Pick one')); + await tester.pump(); + + // Tap an option inside the overlay. + await tester.tap(find.text('Banana').last); + await tester.pump(); + + expect(selected, 'b'); + }); + + testWidgets('WDatePicker fires onChanged when a day is tapped', + (tester) async { + DateTime? picked; + final DateTime anchor = DateTime(2024, 6, 15); + + await tester.pumpWidget( + wrapWithTheme( + WDatePicker( + value: anchor, + placeholder: 'Select date', + onChanged: (d) => picked = d, + className: 'p-3 border rounded', + ), + ), + ); + + // The trigger shows the formatted selected date (value is set), so the + // placeholder text is not rendered. Tap the trigger surface to open. + await tester.tap(find.byType(WDatePicker), warnIfMissed: false); + await tester.pumpAndSettle(); + + // Tap a concrete day cell (10) inside the open calendar. + await tester.tap(find.text('10').last); + await tester.pump(); + + expect(picked, isNotNull); + expect(picked!.day, 10); + }); + }); +} From 66fb5ee475ff049b50c2822996152333ec4748d2 Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Mon, 8 Jun 2026 18:21:56 +0300 Subject: [PATCH 3/6] test(performance): add parser-cache speedup ratio gate + perf report Asserts the cache hit path is >=3x faster than cold (ratio gate, ~26x actual), plus report-only PERF: lines for cache totals and large-tree pump time. --- test/performance/parser_perf_test.dart | 155 +++++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 test/performance/parser_perf_test.dart diff --git a/test/performance/parser_perf_test.dart b/test/performance/parser_perf_test.dart new file mode 100644 index 0000000..3e31823 --- /dev/null +++ b/test/performance/parser_perf_test.dart @@ -0,0 +1,155 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttersdk_wind/src/parser/wind_parser.dart'; +import 'package:fluttersdk_wind/src/theme/wind_theme.dart'; +import 'package:fluttersdk_wind/src/theme/wind_theme_data.dart'; +import 'package:fluttersdk_wind/src/widgets/w_div.dart'; + +/// Number of iterations for the cache cold/warm bench. +const int _kBenchIterations = 10000; + +/// className used for the cache bench. +const String _kBenchClassName = + 'bg-white p-4 m-2 text-lg font-bold rounded-md shadow-sm'; + +/// Returns a [WDiv] node with [className] wrapping [child]. +Widget _styledNode(String className, Widget? child) => WDiv( + className: className, + child: child, + ); + +/// Builds a balanced wide/deep tree of [WDiv] nodes. +/// +/// Strategy: two nesting levels + a wide fan-out so the total node count +/// reaches ~300-500 styled nodes without blowing the widget tree stack. +/// Layout: 20 outer nodes x 20 inner nodes = 400 leaf renders. +Widget _buildLargeTree() { + const outerCount = 20; + const innerCount = 20; + + final outerChildren = List.generate(outerCount, (outerIndex) { + final innerChildren = List.generate(innerCount, (innerIndex) { + // Alternate class names so the parser cache sees diverse keys. + final isEven = (outerIndex + innerIndex) % 2 == 0; + final leafClass = isEven + ? 'bg-gray-100 p-2 text-sm rounded' + : 'bg-blue-50 p-3 text-base font-medium'; + + return _styledNode(leafClass, const SizedBox(width: 20, height: 20)); + }); + + return _styledNode( + 'flex flex-col p-2 m-1 bg-white rounded-md', + Column(children: innerChildren), + ); + }); + + return _styledNode( + 'flex flex-col p-4 bg-gray-50', + Column(children: outerChildren), + ); +} + +void main() { + setUp(WindParser.clearCache); + + group('Parser performance', () { + /// Cache cold vs warm speedup ratio gate. + /// + /// Cold: [WindParser.clearCache] is called before every parse so every + /// call is a cache miss. Warm: the same className is parsed once to prime + /// the cache, then re-parsed [_kBenchIterations] times — every call hits. + /// + /// Assertion: warm total time must be less than cold total time / 3, + /// i.e. the cache must deliver at least a 3x speedup. The multiplier is + /// deliberately generous to stay stable across CI environments. + testWidgets('cache warm path is at least 3x faster than cold path', + (tester) async { + // 1. Capture a real BuildContext from inside the widget tree. + late BuildContext capturedCtx; + await tester.pumpWidget( + MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: Builder( + builder: (context) { + capturedCtx = context; + return const SizedBox(); + }, + ), + ), + ), + ); + + // 2. Cold bench: clear the cache before each iteration. + final coldWatch = Stopwatch()..start(); + for (var i = 0; i < _kBenchIterations; i++) { + WindParser.clearCache(); + WindParser.parse(_kBenchClassName, capturedCtx); + } + coldWatch.stop(); + final coldTotal = coldWatch.elapsedMicroseconds; + + // 3. Verify the cache received exactly one entry after the last miss. + expect(WindParser.cacheSize, 1, + reason: 'cold bench must leave one cache entry after the last miss'); + + // 4. Warm bench: prime the cache once, then hit it [_kBenchIterations] + // times without clearing between iterations. + WindParser.clearCache(); + WindParser.parse(_kBenchClassName, capturedCtx); // prime + final cacheSizeAfterPrime = WindParser.cacheSize; + + final warmWatch = Stopwatch()..start(); + for (var i = 0; i < _kBenchIterations; i++) { + WindParser.parse(_kBenchClassName, capturedCtx); + } + warmWatch.stop(); + final warmTotal = warmWatch.elapsedMicroseconds; + + // 5. Cache must NOT grow during warm iterations (pure hits, no new misses). + expect(WindParser.cacheSize, cacheSizeAfterPrime, + reason: 'cache size must be stable during warm iterations'); + + // 6. Report raw numbers. + debugPrint('PERF: cache cold total = ${coldTotal}us ' + '($_kBenchIterations iters)'); + debugPrint('PERF: cache warm total = ${warmTotal}us ' + '($_kBenchIterations iters)'); + final ratio = coldTotal / (warmTotal == 0 ? 1 : warmTotal); + debugPrint('PERF: cache speedup ratio = ${ratio.toStringAsFixed(2)}x'); + + // 7. Ratio gate: warm must be at least 3x faster than cold. + expect( + warmTotal, + lessThan(coldTotal ~/ 3), + reason: 'warm cache must be at least 3x faster than cold ' + '(cold=${coldTotal}us warm=${warmTotal}us)', + ); + }); + + /// Large-tree render bench: report-only, no hard assert. + /// + /// Builds a 400-node styled [WDiv] tree and pumps it via [pumpWidget], + /// measuring the wall time. The result is printed under the [PERF:] prefix + /// for Step 7 scraping. + testWidgets('large-tree pump time (report-only)', (tester) async { + final tree = MaterialApp( + home: WindTheme( + data: WindThemeData(), + child: SingleChildScrollView(child: _buildLargeTree()), + ), + ); + + final watch = Stopwatch()..start(); + await tester.pumpWidget(tree); + watch.stop(); + + final pumpMs = watch.elapsedMilliseconds; + debugPrint('PERF: large-tree pump = ${pumpMs}ms (400 styled nodes)'); + + // Smoke-check only: the tree must actually render without throwing. + expect(find.byType(WDiv), findsWidgets); + }); + }); +} From 5e225014d6e8c42335a43051410541991036c7df Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Mon, 8 Jun 2026 18:30:18 +0300 Subject: [PATCH 4/6] docs: record final-QA sign-off + nested disabled-hover divergence CHANGELOG Quality entry for the new pixel/interaction/performance regression suites + live dusk validation (no regressions found). Document the disabled- WAnchor-shadowed-by-nested-WDiv hover edge in tailwind-divergence.md. --- CHANGELOG.md | 1 + skills/wind-ui/references/tailwind-divergence.md | 2 ++ 2 files changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b73883..92d7903 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,7 @@ First stable release. wind is utility-first, Tailwind-syntax styling for Flutter - New regression coverage for the arbitrary-hex background (`background_parser_test.dart`), `decoration`-stays-null contract (`wind_style_test.dart`), malformed-JSON graceful degradation (`w_dynamic_renderer_test.dart`), `WindThemeData` value equality (`wind_theme_data_test.dart`), and icon-only button Semantics label (`w_button_test.dart`). - `tool/coverage.sh` portable threshold-aware lcov wrapper; GitHub Actions gate fails any PR dropping below 90%. - Surgical `// coverage:ignore-line` pragmas only on lines structurally unreachable from `flutter test` (`kDebugMode` branches, `dart:io` `Platform.is*` branches not matching the CI host). Each pragma carries a one-line WHY comment. +- Final 1.0.0 QA gate: added permanent regression suites under `test/pixel/` (px-exact geometry + color via `getRect`/`getSize`/`renderObject`, no golden files), `test/interaction/` (tap/hover/focus/disabled/responsive/animation/overlay in-process), and `test/performance/` (parser cache hit/miss speedup ratio gate, ~26x, plus report-only large-tree pump timing). Validated live via a fresh consumer app driven by `fluttersdk_dusk` (all routes navigated, `wind:` enricher 7-field block confirmed, screenshots manually compared). No behavioral regressions found. Production deps: `flutter` (SDK), `flutter_svg ^2.0.0`, `fluttersdk_wind_diagnostics_contracts ^1.0.0`. Dev deps: `flutter_test` (SDK), `flutter_lints ^5.0.0`. Full v1 documentation at [fluttersdk.com/wind](https://fluttersdk.com/wind); LLM-facing skill at `skills/wind-ui/` distributed via [fluttersdk/ai](https://github.com/fluttersdk/ai) (`npx skills add fluttersdk/ai --skill wind-ui`). diff --git a/skills/wind-ui/references/tailwind-divergence.md b/skills/wind-ui/references/tailwind-divergence.md index 91ad6df..902e777 100644 --- a/skills/wind-ui/references/tailwind-divergence.md +++ b/skills/wind-ui/references/tailwind-divergence.md @@ -337,3 +337,5 @@ These canonical Tailwind classes are silently ignored by wind (unknown tokens ar | `text-7xl` / `8xl` / `9xl` | silently capped (max is `text-6xl`) | `text-6xl` or arbitrary `text-[96px]` | Reminder: a wind page needs a Material ancestor (a `Scaffold`) for `WText` to inherit a default text style; without one, Flutter renders the yellow-underline fallback. Real apps always have a `Scaffold`, so this only bites bare `WDiv > WText` route bodies. + +State-shadowing edge: a disabled outer `WAnchor` does NOT suppress a `hover:` class that a nested `WDiv` carries on itself. `WDiv` auto-wraps in its own non-disabled `WAnchor` whose state provider shadows the ancestor's `isDisabled`, so the inner `hover:` still fires. This is an edge case (a disabled control wrapping a separately-hover-styled child); style the disabled state on the same element that owns the `hover:` class, or drive `disabled:` on the inner `WDiv` directly. From 1362f5abf5d1fdaebc808f18229e5c969b58f905 Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Mon, 8 Jun 2026 18:35:59 +0300 Subject: [PATCH 5/6] test(pixel): positively characterize text-7xl no-op (review polish) Both reviewers flagged the weak isNot(72.0) assertion; assert text-7xl falls back to the baseline default size (proving the F11 no-op) instead of only ruling out Tailwind's 72px. --- test/pixel/typography_pixel_test.dart | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/test/pixel/typography_pixel_test.dart b/test/pixel/typography_pixel_test.dart index 9201291..9cb417a 100644 --- a/test/pixel/typography_pixel_test.dart +++ b/test/pixel/typography_pixel_test.dart @@ -61,16 +61,22 @@ void main() { }); testWidgets( - 'text-7xl is a documented no-op (F11): size stays unset, not 72', + 'text-7xl is a documented no-op (F11): falls back to the default size, not 72', (tester) async { + // Baseline: a WText with no text-size class resolves the default size. + await tester.pumpWidget(wrapWithTheme(const WText('base'))); + final baselineSize = _renderedStyle(tester).fontSize; + + // F11: Wind caps at text-6xl; text-7xl is above the cap and silently + // no-ops, so it renders at the SAME default as the baseline (positively + // characterizing the no-op), NOT Tailwind's 72 px. await tester.pumpWidget( wrapWithTheme(const WText('7xl', className: 'text-7xl')), ); + final cappedSize = _renderedStyle(tester).fontSize; - // F11: Wind caps at text-6xl; text-7xl silently falls back. Assert the - // ACTUAL documented behavior (no explicit size), not Tailwind's 72 px. - final size = _renderedStyle(tester).fontSize; - expect(size, isNot(72.0)); + expect(cappedSize, equals(baselineSize)); + expect(cappedSize, isNot(72.0)); }, ); }); From f8e92c7de86d0c9af472efd48bf9f86d57ccdd8d Mon Sep 17 00:00:00 2001 From: Anilcan Cakir Date: Mon, 8 Jun 2026 20:40:08 +0300 Subject: [PATCH 6/6] test(pixel,perf): scope finders to WDiv under test, time only parse() in cold/warm bench Address Copilot review on PR #96: - _decorationOf in color/radius_ring_border pixel tests now scopes find.byType(DecoratedBox) to descendants of the WDiv under test, so an app-shell DecoratedBox can never shadow the asserted decoration. - w-1/2 FractionallySizedBox assertion scoped to the half-keyed WDiv. - parser_perf cold bench moves clearCache() outside the measured window and times only parse() per iteration; warm bench mirrors the per-iteration start/stop so the speedup ratio compares like with like. --- test/performance/parser_perf_test.dart | 16 +++++++++++----- test/pixel/color_pixel_test.dart | 11 ++++++++++- test/pixel/radius_ring_border_pixel_test.dart | 11 ++++++++++- test/pixel/spacing_sizing_pixel_test.dart | 12 ++++++++---- 4 files changed, 39 insertions(+), 11 deletions(-) diff --git a/test/performance/parser_perf_test.dart b/test/performance/parser_perf_test.dart index 3e31823..fc79545 100644 --- a/test/performance/parser_perf_test.dart +++ b/test/performance/parser_perf_test.dart @@ -81,13 +81,16 @@ void main() { ), ); - // 2. Cold bench: clear the cache before each iteration. - final coldWatch = Stopwatch()..start(); + // 2. Cold bench: clear the cache before each iteration, but time only the + // parse() call. clearCache() stays OUTSIDE the measured window so + // coldTotal reflects pure cache-miss parse cost, not map-clear cost. + final coldWatch = Stopwatch(); for (var i = 0; i < _kBenchIterations; i++) { WindParser.clearCache(); + coldWatch.start(); WindParser.parse(_kBenchClassName, capturedCtx); + coldWatch.stop(); } - coldWatch.stop(); final coldTotal = coldWatch.elapsedMicroseconds; // 3. Verify the cache received exactly one entry after the last miss. @@ -100,11 +103,14 @@ void main() { WindParser.parse(_kBenchClassName, capturedCtx); // prime final cacheSizeAfterPrime = WindParser.cacheSize; - final warmWatch = Stopwatch()..start(); + // Time only the parse() call per iteration, matching the cold bench's + // per-iteration start/stop so the speedup ratio compares like with like. + final warmWatch = Stopwatch(); for (var i = 0; i < _kBenchIterations; i++) { + warmWatch.start(); WindParser.parse(_kBenchClassName, capturedCtx); + warmWatch.stop(); } - warmWatch.stop(); final warmTotal = warmWatch.elapsedMicroseconds; // 5. Cache must NOT grow during warm iterations (pure hits, no new misses). diff --git a/test/pixel/color_pixel_test.dart b/test/pixel/color_pixel_test.dart index 9e158f4..e6e8af8 100644 --- a/test/pixel/color_pixel_test.dart +++ b/test/pixel/color_pixel_test.dart @@ -39,9 +39,18 @@ Widget wrapWithDarkTheme(Widget child) { } /// Reads the `BoxDecoration` from the first `RenderDecoratedBox` the WDiv emits. +/// +/// The finder is scoped to descendants of the [WDiv] under test so an unrelated +/// `DecoratedBox` from the app shell (MaterialApp / Scaffold) can never shadow +/// the decoration being asserted on. BoxDecoration _decorationOf(WidgetTester tester) { final render = tester.renderObject( - find.byType(DecoratedBox).first, + find + .descendant( + of: find.byType(WDiv), + matching: find.byType(DecoratedBox), + ) + .first, ); return render.decoration as BoxDecoration; } diff --git a/test/pixel/radius_ring_border_pixel_test.dart b/test/pixel/radius_ring_border_pixel_test.dart index e1bda64..c310349 100644 --- a/test/pixel/radius_ring_border_pixel_test.dart +++ b/test/pixel/radius_ring_border_pixel_test.dart @@ -24,9 +24,18 @@ Widget wrapWithTheme(Widget child) { } /// Reads the `BoxDecoration` from the first `RenderDecoratedBox` the WDiv emits. +/// +/// The finder is scoped to descendants of the [WDiv] under test so an unrelated +/// `DecoratedBox` from the app shell (MaterialApp / Scaffold) can never shadow +/// the decoration being asserted on. BoxDecoration _decorationOf(WidgetTester tester) { final render = tester.renderObject( - find.byType(DecoratedBox).first, + find + .descendant( + of: find.byType(WDiv), + matching: find.byType(DecoratedBox), + ) + .first, ); return render.decoration as BoxDecoration; } diff --git a/test/pixel/spacing_sizing_pixel_test.dart b/test/pixel/spacing_sizing_pixel_test.dart index 62c884e..b662015 100644 --- a/test/pixel/spacing_sizing_pixel_test.dart +++ b/test/pixel/spacing_sizing_pixel_test.dart @@ -205,11 +205,15 @@ void main() { ), ); - // w-1/2 wraps in FractionallySizedBox(widthFactor: 0.5). - expect(find.byType(FractionallySizedBox), findsOneWidget); - final fsb = tester.widget( - find.byType(FractionallySizedBox), + // w-1/2 wraps in FractionallySizedBox(widthFactor: 0.5). Scope the finder + // to the WDiv under test (via its `half` key) so an unrelated + // FractionallySizedBox in the app shell cannot make this assertion fail. + final fractionalFinder = find.descendant( + of: find.byKey(const ValueKey('half')), + matching: find.byType(FractionallySizedBox), ); + expect(fractionalFinder, findsOneWidget); + final fsb = tester.widget(fractionalFinder); expect(fsb.widthFactor, 0.5); // The child is laid out at half of the 400 px bounded parent.