diff --git a/CHANGELOG.md b/CHANGELOG.md index e4f86b0..bdcfe58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `MagicPreview` framework: a dev-only component preview catalog hosted via two + plain pages (`/preview` and `/preview/:component`). New + `package:magic_devtools/preview.dart` barrel exports + the `PreviewEntry` contract (`label`, `slug`, `builder`), the + `MagicPreviewCatalog` widget (a scrollable sidebar next to a SINGLE active + preview pane — tapping a sidebar item, or deep-linking `/preview/`, + swaps the pane to that entry; only the selected preview is mounted, so a + large screen-heavy catalog stays responsive — plus a global light/dark toggle + bound to wind's `WindTheme.of(context).toggleTheme()`), + and the `MagicPreview` registration entrypoint (`register` plus `registerRoutes`). + The route, catalog, and every registered `PreviewEntry` are reachable only + through `MagicPreview.registerRoutes`, which is guarded by `kReleaseMode` plus + `const bool.fromEnvironment('PREVIEW_ENABLED', defaultValue: kDebugMode)`, so + the whole surface const-folds dead and tree-shakes out of release builds. + Entries are held in a function-scoped list (never a top-level const, the + dart-lang/sdk#33920 foot-gun). The generated `_previews.g.dart` (Step 18) feeds + a `List` into `MagicPreview.register`. Consumers must call + `MagicPreview.registerRoutes()` from a provider `boot()` BEFORE the router locks + on first `routerConfig` access, else `/preview` silently never registers. +- `fluttersdk_wind` is now a direct dependency (the catalog renders on + `WDiv`/`WText`/`WAnchor` and binds the theme toggle to `WindThemeController`). - `MagicDuskIntegration.install()` now registers a navigate adapter via `DuskPlugin.registerNavigateAdapter` so `ext.dusk.navigate --route ` drives GoRouter through `MagicRouter.instance.to(path)` instead of falling @@ -16,6 +37,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and `false` when the router is not yet initialised (catches `StateError`). `resetForTesting()` clears the adapter with `DuskPlugin.registerNavigateAdapter(null)`. +### Fixed + +- **`/preview/` deep links now select the right entry**: the catalog moved off a persistent ShellRoute (which did not rebuild when only the child route swapped, leaving every deep link stuck on the first entry) to two plain pages; the `/preview/:component` builder receives the slug and rebuilds on navigation. Known dev-only limitation: feature-SCREEN previews (full controller-backed `MagicStatefulView`s) emit a couple of non-fatal `setState() during build` warnings because the catalog mounts the same screen in both the light and dark panes sharing a singleton controller; the screens render correctly and the real app routes are clean (the catalog is stripped from release). +- **`/preview` route no longer crashes the app**: the catalog group's index child path was `/` which composed to `/preview/`, tripping go_router's `route path may not end with '/'` assertion and blanking the entire app on every route. Changed the index child path to `''` so the composed path is exactly `/preview`. +- **Catalog previews now inherit the host theme**: each light/dark pane copied a bare `WindThemeData` that carried no aliases, so component semantic tokens (`text-fg`, `bg-surface`, ...) resolved to no-ops and every preview rendered Flutter's red unstyled-text fallback. Panes now `copyWith(brightness:)` the ambient app theme, preserving aliases and brand colors. +- **Catalog overflow**: the preview surface now scrolls vertically and each pane scrolls horizontally, so wide variant matrices no longer trigger RenderFlex overflows in the side-by-side light/dark layout. + ## [0.0.1] - 2026-06-17 ### Added diff --git a/README.md b/README.md index 86791b4..55c28d3 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@

Magic adapters for the FlutterSDK dev-tooling ecosystem.
- Wire Magic's runtime into fluttersdk_dusk (E2E driver) and fluttersdk_telescope (runtime inspector) — debug-only, zero release cost. + Wire Magic's runtime into fluttersdk_dusk (E2E driver) and fluttersdk_telescope (runtime inspector), debug-only with zero release cost.

@@ -21,22 +21,23 @@ --- -> **Alpha Release** — part of the Magic ecosystem, under active development. APIs may change before stable. [Star the repo](https://github.com/fluttersdk/magic_devtools) to follow progress. +> **Alpha Release**: part of the Magic ecosystem, under active development. APIs may change before stable. [Star the repo](https://github.com/fluttersdk/magic_devtools) to follow progress. ## What is magic_devtools? `magic_devtools` is the Magic adapter layer for [`fluttersdk_dusk`](https://pub.dev/packages/fluttersdk_dusk) and [`fluttersdk_telescope`](https://pub.dev/packages/fluttersdk_telescope). It enriches dusk snapshots and telescope records with Magic-aware context (forms, navigation, controllers, gates, auth, broadcasting, HTTP) so an LLM agent or CI driver sees your app the way Magic sees it. -It is **debug-only**: you install and wire it under `kDebugMode`, so release builds tree-shake it entirely and it carries no runtime cost in production. This is exactly why it lives outside `magic` core — the framework keeps no dev-tooling production dependencies. +It is **debug-only**: you install and wire it under `kDebugMode`, so release builds tree-shake it entirely and it carries no runtime cost in production. This is exactly why it lives outside `magic` core; the framework keeps no dev-tooling production dependencies. -Two import barrels: +Three import barrels: -- `package:magic_devtools/dusk.dart` — `MagicDuskIntegration` registers 14 Magic-aware enrichers into fluttersdk_dusk's snapshot pipeline. -- `package:magic_devtools/telescope.dart` — `MagicTelescopeIntegration` registers 5 Magic watchers and `MagicHttpFacadeAdapter` into fluttersdk_telescope. +- `package:magic_devtools/dusk.dart`: `MagicDuskIntegration` registers 14 Magic-aware enrichers into fluttersdk_dusk's snapshot pipeline. +- `package:magic_devtools/telescope.dart`: `MagicTelescopeIntegration` registers 5 Magic watchers and `MagicHttpFacadeAdapter` into fluttersdk_telescope. +- `package:magic_devtools/preview.dart`: `MagicPreview` hosts a dev-only component preview catalog via two plain pages (`/preview` and `/preview/:component`), tree-shaken from release builds. ## Install -`magic_devtools` and the tooling packages are imported in `lib/main.dart` (under `kDebugMode`), so they are regular `dependencies`, not `dev_dependencies` — `kDebugMode` tree-shakes them out of release builds, and because `lib/` imports them a `dev_dependencies` entry would trip the `depend_on_referenced_packages` lint. This matches how `fluttersdk_dusk` and `fluttersdk_telescope` are installed on their own. +`magic_devtools` and the tooling packages are imported in `lib/main.dart` (under `kDebugMode`), so they are regular `dependencies`, not `dev_dependencies`; `kDebugMode` tree-shakes them out of release builds, and because `lib/` imports them a `dev_dependencies` entry would trip the `depend_on_referenced_packages` lint. This matches how `fluttersdk_dusk` and `fluttersdk_telescope` are installed on their own. ```yaml dependencies: @@ -77,6 +78,29 @@ if (kDebugMode) { You can wire either integration on its own, or both together: install each plugin before `Magic.init()` and each Magic integration after it. The `dusk:install` and `telescope:install` Artisan commands wire these blocks into `lib/main.dart` automatically when `magic_devtools` is a dependency. +### Preview catalog + +`MagicPreview` hosts a dev-only component preview catalog: a sidebar of registered components next to each preview rendered in BOTH light and dark, with a global theme toggle bound to wind's `WindThemeController`. It is reachable only through `MagicPreview.registerRoutes()`, guarded by `kReleaseMode` plus `const bool.fromEnvironment('PREVIEW_ENABLED', defaultValue: kDebugMode)`, so the route, the catalog, and every registered `PreviewEntry` const-fold dead and tree-shake out of release builds. + +The router-lock timing is load-bearing: `MagicRouter` locks its route table on the first `routerConfig` access, so registration MUST happen in a provider `boot()` (which runs during the Magic bootstrap lifecycle, before `MaterialApp` reads `routerConfig`). Register too late and `/preview` silently never appears. + +```dart +class RouteServiceProvider extends ServiceProvider { + RouteServiceProvider(super.app); + + @override + Future boot() async { + registerAppRoutes(); + if (kDebugMode) { + MagicPreview.register(previewEntries()); // from the generated _previews.g.dart + MagicPreview.registerRoutes(); + } + } +} +``` + +The `previews:refresh` Artisan command scans `*.preview.dart` files and regenerates `previewEntries()` returning a `List` from a function (never a top-level const, the dart-lang/sdk#33920 retention foot-gun). + ## Ecosystem | Package | | @@ -110,11 +134,11 @@ dependency_overrides: ## License -MIT — see [LICENSE](LICENSE) for details. +MIT, see [LICENSE](LICENSE) for details. ---

Built with care by FlutterSDK
- If magic_devtools helps you, give it a star — it helps others discover it. + If magic_devtools helps you, give it a star; it helps others discover it.

diff --git a/lib/preview.dart b/lib/preview.dart new file mode 100644 index 0000000..6440f77 --- /dev/null +++ b/lib/preview.dart @@ -0,0 +1,31 @@ +/// Magic dev-only component preview catalog barrel. +/// +/// Import this file to host the auto-discovered component previews behind two +/// plain pages (`/preview` and `/preview/:component`). The whole surface is +/// dev-only: it is reachable only +/// through [MagicPreview.registerRoutes], which is guarded by `kReleaseMode` + +/// `bool.fromEnvironment('PREVIEW_ENABLED')` and tree-shaken from release +/// builds. +/// +/// Wiring (in the consumer's `RouteServiceProvider.boot()`, which runs BEFORE +/// `MagicRouter.instance.routerConfig` is first accessed — the router locks its +/// route table on that first access): +/// +/// ```dart +/// @override +/// Future boot() async { +/// registerAppRoutes(); +/// if (kDebugMode) { +/// MagicPreview.register(previewEntries()); // from _previews.g.dart +/// MagicPreview.registerRoutes(); +/// } +/// } +/// ``` +/// +/// See `src/preview/magic_preview.dart` for the [PreviewEntry] contract and the +/// [MagicPreviewCatalog] widget, and `src/preview/preview_routes.dart` for the +/// [MagicPreview] registration entrypoint and the release boundary. +library; + +export 'src/preview/magic_preview.dart'; +export 'src/preview/preview_routes.dart'; diff --git a/lib/src/preview/magic_preview.dart b/lib/src/preview/magic_preview.dart new file mode 100644 index 0000000..09a81bb --- /dev/null +++ b/lib/src/preview/magic_preview.dart @@ -0,0 +1,228 @@ +import 'package:flutter/widgets.dart'; +import 'package:fluttersdk_wind/fluttersdk_wind.dart'; + +/// A single entry in the [MagicPreviewCatalog]. +/// +/// Each entry pairs a human [label] and a URL-safe [slug] with a [builder] +/// that renders the component (or component matrix) in isolation. The +/// generated `_previews.g.dart` (auto-discovered from `*.preview.dart` files) +/// returns a `List` from a FUNCTION and feeds it to +/// [MagicPreview.register]; nothing here is ever a top-level const list, so the +/// release-mode tree-shaker can prove the whole catalog unreachable when the +/// dev-only boundary in [MagicPreview.registerRoutes] folds dead. +@immutable +final class PreviewEntry { + /// Creates a preview entry. + const PreviewEntry({ + required this.label, + required this.slug, + required this.builder, + }); + + /// The display name shown in the catalog sidebar (e.g. `Button`). + final String label; + + /// The URL-safe identifier used as the `:component` route segment + /// (e.g. `button`). Must be unique across the registered set; the + /// `previews:refresh` codegen (Step 18) collision-checks slugs at build time. + final String slug; + + /// Builds the preview body for this entry. + final WidgetBuilder builder; +} + +/// **The dev-only component preview catalog.** +/// +/// Renders a scrollable sidebar of [PreviewEntry] labels next to a SINGLE +/// active preview pane: tapping a sidebar item (or deep-linking +/// `/preview/`) swaps the pane to that entry. Only the selected preview +/// is built, so a large catalog (including heavy controller-backed screen +/// previews) stays responsive instead of mounting every section at once. The +/// header shows the active entry's label plus a "Toggle theme" button bound to +/// wind's [WindThemeController] for a global light/dark flip. +/// +/// ### Release boundary +/// +/// This widget is only ever instantiated from within +/// [MagicPreview.registerRoutes], which is guarded by `kReleaseMode` + +/// `bool.fromEnvironment('PREVIEW_ENABLED')`. It must never be referenced from +/// a top-level const/final collection (the dart-lang/sdk#33920 foot-gun that +/// retains widget refs in release); keep every reference inside a function +/// body. +class MagicPreviewCatalog extends StatefulWidget { + /// Creates the catalog over [entries]. + /// + /// [activeSlug] selects which entry is shown; when null (or unmatched) the + /// first entry is shown. + const MagicPreviewCatalog({ + super.key, + required this.entries, + this.activeSlug, + this.onSelect, + }); + + /// The previews to host. Passed in (never read from a top-level const) so the + /// release boundary can stay airtight. + final List entries; + + /// The slug of the entry to display; null shows the first entry. + final String? activeSlug; + + /// Invoked when a sidebar item is tapped. The `/preview` route wires this to + /// navigation; when null, selection updates local state only. + final ValueChanged? onSelect; + + @override + State createState() => _MagicPreviewCatalogState(); +} + +class _MagicPreviewCatalogState extends State { + late String _selectedSlug; + + @override + void initState() { + super.initState(); + _selectedSlug = _resolveInitialSlug(); + } + + @override + void didUpdateWidget(MagicPreviewCatalog oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.activeSlug != oldWidget.activeSlug || + widget.entries != oldWidget.entries) { + _selectedSlug = _resolveInitialSlug(); + } + } + + /// Pick the active slug: the requested one when it matches an entry, + /// otherwise the first entry's slug, otherwise the empty string. + String _resolveInitialSlug() { + if (widget.entries.isEmpty) return ''; + final String? requested = widget.activeSlug; + if (requested != null && widget.entries.any((e) => e.slug == requested)) { + return requested; + } + return widget.entries.first.slug; + } + + PreviewEntry? get _active { + for (final PreviewEntry entry in widget.entries) { + if (entry.slug == _selectedSlug) return entry; + } + return widget.entries.isEmpty ? null : widget.entries.first; + } + + void _select(PreviewEntry entry) { + setState(() => _selectedSlug = entry.slug); + widget.onSelect?.call(entry); + } + + @override + Widget build(BuildContext context) { + return WDiv( + className: 'flex flex-row w-full h-full bg-surface', + children: [ + _buildSidebar(), + WDiv( + className: 'flex flex-col flex-1 h-full', + children: [ + _buildHeader(context), + // Only the active preview is mounted; it scrolls vertically so a + // tall matrix or screen does not overflow the viewport. + Expanded( + child: SingleChildScrollView( + child: WDiv(className: 'p-6', child: _buildActivePane()), + ), + ), + ], + ), + ], + ); + } + + /// The left navigation rail. The list scrolls independently (it can hold many + /// more entries than fit the viewport height) under a fixed header. + Widget _buildSidebar() { + return WDiv( + className: + 'flex flex-col w-56 h-full ' + 'bg-surface-container border-r border-color-border', + children: [ + const WText( + 'Previews', + className: 'text-fg-muted text-xs font-semibold uppercase px-6 py-4', + ), + Expanded( + child: SingleChildScrollView( + child: WDiv( + className: 'flex flex-col px-3 pb-3 gap-1', + children: [ + for (final PreviewEntry entry in widget.entries) + WAnchor( + key: ValueKey('magic-preview-nav-${entry.slug}'), + onTap: () => _select(entry), + child: WDiv( + className: entry.slug == _selectedSlug + ? 'px-3 py-2 rounded-md bg-primary-container' + : 'px-3 py-2 rounded-md hover:bg-surface-container-high', + child: WText( + entry.label, + className: entry.slug == _selectedSlug + ? 'text-sm text-fg' + : 'text-sm text-fg-muted', + ), + ), + ), + ], + ), + ), + ), + ], + ); + } + + /// The toolbar with the active entry's title and the wind theme toggle. + Widget _buildHeader(BuildContext context) { + final PreviewEntry? active = _active; + return WDiv( + className: + 'flex flex-row items-center justify-between ' + 'px-6 py-4 border-b border-color-border bg-surface', + children: [ + WText( + active?.label ?? 'No previews', + className: 'text-fg text-lg font-semibold', + ), + WAnchor( + key: const ValueKey('magic-preview-theme-toggle'), + // Flip dark/light for the whole catalog via wind's theme controller. + onTap: () => WindTheme.of(context).toggleTheme(), + child: WDiv( + className: + 'px-3 py-2 rounded-md bg-surface-container ' + 'border border-color-border', + child: const WText( + 'Toggle theme', + className: 'text-sm text-fg-muted', + ), + ), + ), + ], + ); + } + + /// Render the active preview in a bordered card under the ambient wind theme. + Widget _buildActivePane() { + final PreviewEntry? active = _active; + if (active == null) { + return const WText( + 'Register a preview to see it here.', + className: 'text-fg-muted text-sm', + ); + } + return WDiv( + className: 'p-6 rounded-lg border border-color-border bg-surface', + child: Builder(builder: (paneContext) => active.builder(paneContext)), + ); + } +} diff --git a/lib/src/preview/preview_routes.dart b/lib/src/preview/preview_routes.dart new file mode 100644 index 0000000..dff154c --- /dev/null +++ b/lib/src/preview/preview_routes.dart @@ -0,0 +1,122 @@ +import 'package:flutter/foundation.dart'; +import 'package:magic/magic.dart'; + +import 'magic_preview.dart'; + +/// Compile-time switch for the preview catalog. +/// +/// Defaults to [kDebugMode]: ON in debug builds, OFF in profile and release +/// (`kDebugMode` is false in both). A profile build can opt in with +/// `--dart-define=PREVIEW_ENABLED=true`; a debug build can force it off with +/// `--dart-define=PREVIEW_ENABLED=false`. Release is always blocked by the +/// `kReleaseMode` guard in [MagicPreview.registerRoutes] regardless. Because +/// this is a `const`, the release-mode optimizer can fold the guarded branch in +/// [MagicPreview.registerRoutes] dead and tree-shake the entire catalog, +/// every [PreviewEntry], and every builder it transitively references. +/// release-mode optimizer can fold the guarded branch in +/// [MagicPreview.registerRoutes] dead and tree-shake the entire catalog, +/// every [PreviewEntry], and every builder it transitively references. +const bool kPreviewEnabled = bool.fromEnvironment( + 'PREVIEW_ENABLED', + defaultValue: kDebugMode, +); + +/// **The dev-only preview registration entrypoint.** +/// +/// The generated `_previews.g.dart` (Step 18) returns a `List` +/// from a FUNCTION and feeds it here: +/// +/// ```dart +/// // lib/_previews.g.dart (generated) +/// List previewEntries() => [ ... ]; +/// +/// // RouteServiceProvider.boot() +/// MagicPreview.register(previewEntries()); +/// MagicPreview.registerRoutes(); +/// ``` +/// +/// Entries are held in a function-scoped static list (never a top-level const +/// holding widget refs — the dart-lang/sdk#33920 foot-gun), assigned only from +/// [register], so the release-mode tree-shaker can prove them unreachable once +/// [registerRoutes] folds dead behind [kReleaseMode] + [kPreviewEnabled]. +final class MagicPreview { + MagicPreview._(); + + /// The registered previews. Populated by [register]; empty until then. + static List _entries = const []; + + /// The currently registered previews (read-only view). + static List get entries => List.unmodifiable(_entries); + + /// Register the catalog [entries]. + /// + /// Call this BEFORE [registerRoutes], typically from the consumer's + /// `RouteServiceProvider.boot()`. In release builds the guard in + /// [registerRoutes] short-circuits, so even if entries are registered they + /// are never wired into a route and stay tree-shakeable. + static void register(List entries) { + // Defensively snapshot into an unmodifiable list so later mutation of the + // caller's list cannot change the registered catalog after the fact. + _entries = List.unmodifiable(entries); + } + + /// Register the `/preview` catalog page and its `/preview/:component` deep + /// link. + /// + /// ## Router-lock timing (load-bearing) + /// + /// [MagicRouter] locks its route table the first time `routerConfig` is + /// accessed (`MagicRouter.routerConfig` builds the GoRouter and sets + /// `_isBuilt`, after which `addRoute` throws `StateError`). The consumer MUST + /// therefore call [registerRoutes] inside a provider `boot()` — which runs + /// during the Magic bootstrap lifecycle, BEFORE `MaterialApp` reads + /// `MagicRouter.instance.routerConfig` — otherwise the routes register too + /// late and `/preview` silently never appears. + /// + /// ## Release boundary + /// + /// The body is gated by `kReleaseMode` (early return) and [kPreviewEnabled] + /// (a `const bool.fromEnvironment`). Both fold to a dead branch in release, + /// so the route, the [MagicPreviewCatalog], and every registered + /// [PreviewEntry] are proven unreachable and tree-shaken from the bundle. + static void registerRoutes() { + // 1. Hard release boundary: nothing below this line survives a release + // build (const-folded dead by the optimizer). + if (kReleaseMode) return; + if (!kPreviewEnabled) return; + + // 2. Snapshot the entries inside this function body (never a top-level + // const list — sdk#33920) so the catalog widget receives them by value. + final List entries = _entries; + + // 3. Two plain pages render the catalog DIRECTLY (no persistent shell): the + // index shows the first entry; `/preview/:component` selects an entry by + // its slug. The `:component` builder RECEIVES the slug and rebuilds on + // every navigation, so deep-linking (`/preview/`) and sidebar + // selection both resolve the right entry. A persistent ShellRoute would + // NOT rebuild when only the child route swapped, leaving the catalog + // stuck on the first entry. + MagicRoute.page( + '/preview', + () => MagicPreviewCatalog( + entries: entries, + onSelect: (entry) => MagicRoute.to('/preview/${entry.slug}'), + ), + ).name('magic-preview.index'); + + MagicRoute.page( + '/preview/:component', + (String component) => MagicPreviewCatalog( + entries: entries, + activeSlug: component, + onSelect: (entry) => MagicRoute.to('/preview/${entry.slug}'), + ), + ).name('magic-preview.component'); + } + + /// Test-only reset of the registered entries. + @visibleForTesting + static void resetForTesting() { + _entries = const []; + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 1319f4c..bc0b05e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -15,6 +15,7 @@ dependencies: magic: ^0.0.3 fluttersdk_dusk: ^0.0.8 fluttersdk_telescope: ^0.0.4 + fluttersdk_wind: ^1.1.2 dev_dependencies: flutter_test: diff --git a/test/preview/magic_preview_test.dart b/test/preview/magic_preview_test.dart new file mode 100644 index 0000000..ebb7564 --- /dev/null +++ b/test/preview/magic_preview_test.dart @@ -0,0 +1,160 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttersdk_wind/fluttersdk_wind.dart'; +import 'package:magic_devtools/preview.dart'; + +/// Tests for the [MagicPreviewCatalog] dev-only component preview framework. +/// +/// The catalog hosts auto-discovered [PreviewEntry] widgets. It renders a +/// scrollable sidebar next to a SINGLE active pane: only the selected entry is +/// built (so a large, screen-heavy catalog stays responsive), and the header +/// toggle flips light/dark. These tests mount the catalog with two fake +/// entries, prove single-pane rendering, exercise the theme toggle, and check +/// that a sidebar tap reports + shows the selected entry. + +/// A trivial preview body that paints a brightness-derived label so a test can +/// read which [Brightness] the surrounding [WindTheme] resolved to. +class _BrightnessProbe extends StatelessWidget { + const _BrightnessProbe({required this.tag}); + + final String tag; + + @override + Widget build(BuildContext context) { + final Brightness brightness = WindTheme.dataOf(context).brightness; + final String mode = brightness == Brightness.dark ? 'dark' : 'light'; + return WText('$tag:$mode', className: 'text-fg'); + } +} + +List _fakeEntries() { + return [ + PreviewEntry( + label: 'Alpha', + slug: 'alpha', + builder: (context) => const _BrightnessProbe(tag: 'alpha'), + ), + PreviewEntry( + label: 'Beta', + slug: 'beta', + builder: (context) => const _BrightnessProbe(tag: 'beta'), + ), + ]; +} + +Widget _mountCatalog( + List entries, { + String? slug, + ValueChanged? onSelect, +}) { + return WindTheme( + data: WindThemeData(brightness: Brightness.light, syncWithSystem: false), + builder: (context, controller) => MaterialApp( + theme: controller.toThemeData(), + home: MagicPreviewCatalog( + entries: entries, + activeSlug: slug, + onSelect: onSelect, + ), + ), + ); +} + +void main() { + setUp(WindParser.clearCache); + + group('PreviewEntry', () { + test('carries label, slug, and builder', () { + final entry = PreviewEntry( + label: 'Button', + slug: 'button', + builder: (context) => const SizedBox.shrink(), + ); + + expect(entry.label, 'Button'); + expect(entry.slug, 'button'); + expect(entry.builder, isNotNull); + }); + }); + + group('MagicPreviewCatalog', () { + testWidgets('renders only the active preview in the ambient brightness', ( + tester, + ) async { + await tester.pumpWidget(_mountCatalog(_fakeEntries(), slug: 'alpha')); + await tester.pump(); + + // Single pane: only the selected entry's body is mounted (light here). + expect(find.text('alpha:light'), findsOneWidget); + expect(find.text('beta:light'), findsNothing); + expect(find.text('alpha:dark'), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('lists every entry in the sidebar', (tester) async { + await tester.pumpWidget(_mountCatalog(_fakeEntries())); + await tester.pump(); + + expect(find.text('Alpha'), findsWidgets); + expect(find.text('Beta'), findsWidgets); + expect(tester.takeException(), isNull); + }); + + testWidgets('defaults to the first entry when no slug is given', ( + tester, + ) async { + await tester.pumpWidget(_mountCatalog(_fakeEntries())); + await tester.pump(); + + expect(find.text('alpha:light'), findsOneWidget); + expect(find.text('beta:light'), findsNothing); + }); + + testWidgets('toggling the wind theme flips the active pane brightness', ( + tester, + ) async { + await tester.pumpWidget(_mountCatalog(_fakeEntries(), slug: 'beta')); + await tester.pump(); + + final BuildContext context = tester.element( + find.byType(MagicPreviewCatalog), + ); + final WindThemeController controller = WindTheme.of(context); + expect(controller.brightness, Brightness.light); + + await tester.tap( + find.byKey(const ValueKey('magic-preview-theme-toggle')), + ); + await tester.pump(); + + expect(controller.brightness, Brightness.dark); + expect(find.text('beta:dark'), findsOneWidget); + expect(find.text('beta:light'), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('tapping a sidebar item selects and shows that entry', ( + tester, + ) async { + PreviewEntry? selected; + await tester.pumpWidget( + _mountCatalog(_fakeEntries(), onSelect: (entry) => selected = entry), + ); + await tester.pump(); + + // Starts on the first entry. + expect(find.text('alpha:light'), findsOneWidget); + expect(find.text('beta:light'), findsNothing); + + await tester.tap(find.byKey(const ValueKey('magic-preview-nav-beta'))); + await tester.pump(); + + // Swaps the pane to the tapped entry and reports it. + expect(selected, isNotNull); + expect(selected!.slug, 'beta'); + expect(find.text('beta:light'), findsOneWidget); + expect(find.text('alpha:light'), findsNothing); + expect(tester.takeException(), isNull); + }); + }); +} diff --git a/test/preview/preview_routes_test.dart b/test/preview/preview_routes_test.dart new file mode 100644 index 0000000..e675b48 --- /dev/null +++ b/test/preview/preview_routes_test.dart @@ -0,0 +1,102 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/magic.dart'; +import 'package:magic_devtools/preview.dart'; + +/// Tests for [MagicPreview]'s registration entrypoint and the two plain +/// `/preview` pages (`/preview` and `/preview/:component`) it wires into +/// [MagicRouter] (a persistent ShellRoute is deliberately avoided — it would +/// not rebuild when only the child route swaps). +/// +/// The release boundary itself (the `kReleaseMode` early return + tree-shaking) +/// is asserted by Step 19 (a release-bundle symbol grep); these tests cover the +/// debug-mode behavior: entries round-trip, and `registerRoutes` adds the +/// `magic-preview` pages BEFORE the router locks. + +List _entries() { + return [ + PreviewEntry( + label: 'Alpha', + slug: 'alpha', + builder: (context) => const SizedBox.shrink(), + ), + PreviewEntry( + label: 'Beta', + slug: 'beta', + builder: (context) => const SizedBox.shrink(), + ), + ]; +} + +void main() { + setUp(() { + MagicRouter.reset(); + MagicPreview.resetForTesting(); + }); + + tearDown(() { + MagicRouter.reset(); + MagicPreview.resetForTesting(); + }); + + group('MagicPreview.register', () { + test('round-trips the registered entries', () { + final entries = _entries(); + MagicPreview.register(entries); + + expect(MagicPreview.entries, hasLength(2)); + expect(MagicPreview.entries.map((e) => e.slug), ['alpha', 'beta']); + }); + + test('exposes an unmodifiable view of the entries', () { + MagicPreview.register(_entries()); + + expect( + () => MagicPreview.entries.add( + PreviewEntry( + label: 'X', + slug: 'x', + builder: (context) => const SizedBox.shrink(), + ), + ), + throwsUnsupportedError, + ); + }); + }); + + group('MagicPreview.registerRoutes', () { + test('registers the /preview page and the :component deep link', () { + // In the test runtime kReleaseMode is false and kPreviewEnabled defaults + // to kDebugMode (true), so the guarded body runs. + MagicPreview.register(_entries()); + MagicPreview.registerRoutes(); + + final paths = MagicRouter.instance.routes + .map((RouteDefinition r) => r.fullPath) + .toList(); + + // Two plain pages render the catalog directly (no persistent shell): the + // index and the slug deep link. The :component builder rebuilds on nav so + // direct navigation to /preview/ selects the right entry. + expect(paths, contains('/preview')); + expect(paths, contains('/preview/:component')); + + // Regression guard for the boot crash: go_router asserts a route path may + // not end with '/' (except the root). A '/preview/' here blanks the whole + // app at router-config build time. + expect( + paths.where((String p) => p != '/' && p.endsWith('/')), + isEmpty, + reason: 'no registered route path may end with "/"', + ); + }); + + test('registers before the router locks (no StateError)', () { + MagicPreview.register(_entries()); + + // Registration must succeed because we have not accessed routerConfig + // yet; doing so would lock the route table and make addLayout throw. + expect(MagicPreview.registerRoutes, returnsNormally); + }); + }); +}