diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index cc3fac53..12bac3bc 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -44,7 +44,7 @@ Standard Flutter commands (`flutter test`, `dart analyze`, `dart format .`, `flu | `flutter test test/` | Single module | | `dart pub publish --dry-run` | Pre-release validation | | `dart run :artisan magic:install` | One-shot consumer bootstrap (hybrid installer) | -| `dart run :artisan make:model User -mcf` | Generators: 14 `make:*` + `key:generate` | +| `dart run :artisan make:model User -mcf` | Generators: 20 `make:*` + `key:generate` | Magic's CLI is an `fluttersdk_artisan` plugin (`MagicArtisanProvider`); a consumer runs it through its own artisan dispatcher (`dart run :artisan `), not as a global activate. There is no standalone magic executable beyond the artisan plugin surface. @@ -67,7 +67,7 @@ lib/ ├── http/ # MagicController, middleware pipeline, Kernel ├── concerns/ # ValidatesRequests mixin (import from here, NOT http/) ├── localization/ logging/ routing/ support/ validation/ ui/ - └── cli/ # magic:install + 14 make:* generators on fluttersdk_artisan + └── cli/ # magic:install + 20 make:* generators on fluttersdk_artisan ``` ## Testing rule that catches everyone diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 79d3826f..f22e8bfe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,3 +37,22 @@ jobs: with: files: coverage/lcov.info fail_ci_if_error: false + + generated-code-analyzes: + name: Generated code analyzes + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v7 + + - name: Setup Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + cache: true + + - name: Install dependencies + run: flutter pub get + + - name: Run every make:* generator and analyze its output + run: flutter test --tags integration --run-skipped diff --git a/CHANGELOG.md b/CHANGELOG.md index 2aa103d6..ab45c0b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,9 +4,32 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +### BREAKING + +- **`make:controller`'s plain and `--resource` stubs, and `make:view`'s stateful stub, changed shape.** The resource controller stub dropped its own hand-rolled CRUD/state scaffolding in favour of the `RepositoryQuery`-backed read state `MakeControllerCommand` now assembles per flag (`--actions`, `--broadcasts`, `--timers`, `--validates`, plus a `resetForSession()` implementing `SessionScoped`); the plain controller stub gained the same `SessionScoped` shape with an empty `resetForSession()`. `make:view --stateful` now writes a `MagicStatefulView<Controller>` instead of a plain `StatefulWidget`, so the derived `Controller` has to exist (or be named with `--controller=`) for the view to compile; the new `--list` adds `RefetchesOnMount` over a `--resource` controller, and `--form=` adds a State-owned form object disposed in `onClose`. A controller or stateful view generated before this change keeps its old shape until regenerated; regenerating with `--force` overwrites any hand-edited body, so review the diff first. (`lib/src/cli/commands/make_controller_command.dart`, `lib/src/cli/commands/make_view_command.dart`, `assets/stubs/controller.stub`, `assets/stubs/controller.resource.stub`, `assets/stubs/view.stateful.stub`) +- **`make:component` no longer always scaffolds the preview file and chains `previews:refresh`.** It now writes `.preview.dart` only when the target project already maintains a preview catalogue (any `*.preview.dart` file or a `_previews.g.dart` index under `lib/`); pass `--preview` or `--no-preview` to force the direction explicitly. A project with no preview catalogue yet used to get one scaffolded regardless. (`lib/src/cli/commands/make_component_command.dart`) +- **Exporting `ActionRequestFailed` from `lib/magic.dart` conflicts with an app class of the same name until the app imports magic's.** Dart resolves a bare `ActionRequestFailed` reference in a file that neither declares nor imports one of its own to the export from `package:magic/magic.dart`; an app that already declares its own `ActionRequestFailed` sees an ambiguous-import error the moment both land in scope, and must rename its own class or import magic's explicitly instead. (`lib/magic.dart`, `lib/src/actions/action_request_failed.dart`) + ### Added - **`references/plugin-sentry.md`, the skill's page for `magic_sentry` 0.0.1.** The ecosystem plugin table, the reference index and the skill's trigger line now name the package, and the page covers the boot order (`MagicSentry.run` before `Magic.init`), what `SentryServiceProvider.boot()` wires, how `SentryNetworkInterceptor` sorts an HTTP failure into an event or a breadcrumb, the scope user, `ReportsBreadcrumb` events, and the published `.env` config. (`skills/magic-framework/`) +- **`make:resource`, the artisan generator composing a full CRUD vertical for a model.** Magic's analogue of Laravel's `make:model --all`: model and factory, repository, the create/update/delete actions, the Store and Update requests, the resource form object, a `--resource --actions` controller, and the list and form views, plus the tests for the actions, the form and the controller. Every file comes from its own owning generator through `RunChild`; the run preflights every target path first, so a clash anywhere (other than a kept model or factory) fails the whole run with nothing written unless `--force` is passed. `--no-model` leaves the model and factory out (an existing model and factory are kept either way); `--no-views` stops after the data and write layers. The index and create route lines print for `RouteServiceProvider.boot()`, never write into it. A new integration test (tagged `integration`, run in CI as its own job) generates every `make:*` output into a scratch Flutter project and requires `flutter analyze` to report zero issues. (`lib/src/cli/commands/make_resource_command.dart`, `test/cli/integration/generated_code_analyzes_test.dart`, `dart_test.yaml`, `.github/workflows/ci.yml`, `skills/magic-framework/`) +- **`make:test --kind=`, the artisan generator for a test skeleton mirroring another `make:*` generator's own output.** Covers `controller`, `action`, `form`, `repository`, `request`, `view` and `unit`, resolving the target project's package name from its own `pubspec.yaml` so the generated test imports app code as `package:/...` rather than a relative `../lib/` path (which trips `avoid_relative_lib_imports`). `make:controller`, `make:view`, `make:action`, `make:form`, `make:repository` and `make:request` now accept `--test`, chaining `make:test` onto a successful write so the matching test lands in the same run; `--force` on the host forwards to the chained test too, and a refused test write fails the host with exit 1. `make:component` now also writes a widget test for the component, importing its barrel with a prefix so a component named like a Material widget (`Badge`, `Card`) stays unambiguous; a test already at that path fails the run before anything is written unless `--force` is passed. (`lib/src/cli/commands/make_test_command.dart`, `lib/src/cli/commands/make_component_command.dart`, `lib/src/cli/helpers/creates_matching_test.dart`, `lib/src/cli/helpers/run_child.dart`, `skills/magic-framework/`) +- **`make:action --kind=create|update|delete --model=`, the write variants of the plain `make:action` generator.** `create` fills and saves a new model, `update` saves an edit and writes it into `Repository`, `delete` deletes and evicts it; a refused save throws `ActionRequestFailed`. `--kind` requires `--model`. (`lib/src/cli/commands/make_action_command.dart`, `assets/stubs/action.create.stub`, `assets/stubs/action.update.stub`, `assets/stubs/action.delete.stub`, `skills/magic-framework/`) +- **`make:form --resource=`, the full create/edit variant of the plain `make:form` generator.** Scaffolds an `editing` field, `initial` seeded from `editing?.toArray()`, a `request` choosing between the model's Store/Update requests, and a `persist` running the matching Create/Update action (a create then reloads the `--resource` `Controller`, so it expects the controller `make:resource` writes), instead of the plain skeleton's TODO placeholders. (`lib/src/cli/commands/make_form_command.dart`, `assets/stubs/form.resource.stub`, `skills/magic-framework/`) +- **`ActionRequestFailed`, the exception a refused `MagicAction` write throws.** `ActionRequestFailed.refusalOf(action, errors, [response])` answers a `ValidationException` (first message per field) when `errors` carries any, otherwise an `ActionRequestFailed` carrying the refusing response; `retryAfterSeconds` reads a 429 body's `retry_after_seconds`, falling back to 1. (`lib/src/actions/action_request_failed.dart`, `doc/basics/actions.md`, `skills/magic-framework/`) +- **`make:model`'s generated stub now includes a static `fromMap(Map map)` factory.** Hydrates the model directly via `setRawAttributes`, bypassing the `fillable` mass-assignment guard (unlike `fill`), and sets `exists` from whether the map carries an `id` key, matching how a factory or a repository already builds a model from raw API data. (`assets/stubs/model.stub`, `skills/magic-framework/`) +- **`make:enum --wire`, a wire-backed variant of the plain `make:enum` generator.** Adds an `unknown` fallback case, a `fromWire()` factory that never throws on an unrecognised value, and a `trans()`-backed `label` getter, for an enum mirroring a backend string value. (`lib/src/cli/commands/make_enum_command.dart`, `assets/stubs/enum.wire.stub`, `skills/magic-framework/`) +- **`make:lang --from=`, copying an existing language file's key tree into a new one.** Defaults to `en`; when `assets/lang/.json` exists, the new locale's file carries the same keys with each leaf copied verbatim (a complete catalogue ready for a human translator) instead of an empty `{}`. A source that is not valid JSON fails with exit 1 and writes nothing. (`lib/src/cli/commands/make_lang_command.dart`, `skills/magic-framework/`) + +### Changed + +- **`make:request` now writes a `FormRequest` subclass with a `const` constructor and a `rules()` override, and accepts `--test`.** The old stub was a plain class wrapping `Validator.make`, which neither `MagicFormObject.request` nor `ValidatesRequests.validateRequest` accepts. (`lib/src/cli/commands/make_request_command.dart`, `assets/stubs/request.stub`, `skills/magic-framework/`) +- **`make:model --all` also writes the model's repository, and passes `--model=` to the resource controller it chains**, since that controller now reads through `Repository`. (`lib/src/cli/commands/make_model_command.dart`) + +### Fixed + +- **`make:model` now exits 1 when the model file already exists without `--force`, instead of logging the clash and generating the requested companions (migration, factory, seeder, policy, controller) against it anyway.** (`lib/src/cli/commands/make_model_command.dart`) ## [0.0.22] - 2026-09-27 diff --git a/CLAUDE.md b/CLAUDE.md index e552ca34..0d65c468 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,7 +48,7 @@ Standard Flutter commands (`flutter test`, `dart analyze`, `dart format .`, `flu | `flutter test test/` | Single module | | `dart pub publish --dry-run` | Pre-release validation | | `dart run magic:artisan magic:install` | One-shot consumer bootstrap (hybrid installer) | -| `dart run magic:artisan make:model User -mcf` | Generators: 14 `make:*` + `key:generate` | +| `dart run magic:artisan make:model User -mcf` | Generators: 20 `make:*` + `key:generate` | Magic ships an `artisan` executable (`pubspec.yaml` `executables: { artisan: }`, backed by `bin/artisan.dart`), so a consumer runs any command with `dart run magic:artisan ` once `magic` is a dependency: no global activate, no app-specific package name. `MagicArtisanProvider` supplies the commands; it also plugs into a consumer app's own aggregated artisan dispatcher when one exists. @@ -71,7 +71,7 @@ lib/ ├── http/ # MagicController, middleware pipeline, Kernel ├── concerns/ # ValidatesRequests mixin (import from here, NOT http/) ├── localization/ logging/ routing/ support/ validation/ ui/ - └── cli/ # magic:install + 14 make:* generators on fluttersdk_artisan + └── cli/ # magic:install + 20 make:* generators on fluttersdk_artisan ``` ## Testing rule that catches everyone diff --git a/README.md b/README.md index 1dc11001..acb869bf 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ The same Facades, the same Eloquent syntax, the same Service Provider lifecycle | 📡 | **Broadcasting** | Laravel Echo equivalent: real-time WebSocket channels via the `Echo` facade with presence support and `Echo.fake()`. | | 🔄 | **Offline Sync** | `SyncFeed` runs a push-then-pull loop over any REST resource, bookmarked by `SyncLedger`'s `sync_cursors` table (`CreateSyncCursorsTable`). | | 🧪 | **Testing** | First-class fakes: `Http.fake()`, `Auth.fake()`, `Cache.fake()`, `Vault.fake()`, `Log.fake()`, `Echo.fake()`. No mockito needed. | -| 🧰 | **Magic CLI** | Artisan-style scaffolding via `dart run magic:artisan make:model`, `make:controller`, 15 generators, plus `make:component` for design-first component workflows and `design:sync` / `design:lint` to drive the Wind theme from a `DESIGN.md`. | +| 🧰 | **Magic CLI** | Artisan-style scaffolding via `dart run magic:artisan make:model`, `make:controller`, 20 `make:*` generators (including `make:resource`, which composes a full CRUD vertical: model, repository, actions, requests, form, controller, views and tests), plus `make:component` for design-first component workflows and `design:sync` / `design:lint` to drive the Wind theme from a `DESIGN.md`. | ## A taste of Magic diff --git a/assets/stubs/action.create.stub b/assets/stubs/action.create.stub new file mode 100644 index 00000000..8b78a98e --- /dev/null +++ b/assets/stubs/action.create.stub @@ -0,0 +1,24 @@ +import 'package:magic/magic.dart'; + +{{ modelImport }} + +/// {{ className }} +/// +/// Creates a [{{ modelName }}] from an already-validated field map (see +/// [MagicAction]). A refused save throws [ActionRequestFailed] carrying the +/// field errors from `validationErrors`. +class {{ className }} extends MagicAction, {{ modelName }}> { + const {{ className }}(); + + @override + Future<{{ modelName }}> handle(Map fields) async { + final {{ modelName }} {{ modelVariable }} = {{ modelName }}() + ..fill(fields, strict: true); + if (await {{ modelVariable }}.save()) return {{ modelVariable }}; + + throw ActionRequestFailed.refusalOf( + 'create', + {{ modelVariable }}.validationErrors, + ); + } +} diff --git a/assets/stubs/action.delete.stub b/assets/stubs/action.delete.stub new file mode 100644 index 00000000..1f294b66 --- /dev/null +++ b/assets/stubs/action.delete.stub @@ -0,0 +1,22 @@ +import 'package:magic/magic.dart'; + +{{ modelImport }} +{{ repositoryImport }} + +/// {{ className }} +/// +/// Deletes a [{{ modelName }}] through the ORM and evicts it from +/// [{{ repositoryName }}], so every screen reading the inventory loses the +/// row at once. +class {{ className }} extends MagicAction<{{ modelName }}, void> { + const {{ className }}(); + + @override + Future handle({{ modelName }} {{ modelVariable }}) async { + final bool deleted = await {{ modelVariable }}.delete(); + if (!deleted) throw ActionRequestFailed('delete {{ modelIdInterpolation }}'); + + // The repository keys rows by the id's string form, whatever its type. + {{ repositoryName }}.instance.evict('{{ modelIdInterpolation }}'); + } +} diff --git a/assets/stubs/action.update.stub b/assets/stubs/action.update.stub new file mode 100644 index 00000000..3699c677 --- /dev/null +++ b/assets/stubs/action.update.stub @@ -0,0 +1,36 @@ +import 'package:magic/magic.dart'; + +{{ modelImport }} +{{ repositoryImport }} + +/// {{ className }} +/// +/// Saves an edit to a [{{ modelName }}] from an already-validated field map +/// and writes it into [{{ repositoryName }}]. Answers the saved +/// {{ modelName }}, or null when it no longer resolves (no field to flag, so +/// nothing is written). A refused save throws +/// [ActionRequestFailed] carrying the field errors from `validationErrors`. +class {{ className }} + extends MagicAction<({String id, Map fields}), {{ modelName }}?> { + const {{ className }}(); + + @override + Future<{{ modelName }}?> handle( + ({String id, Map fields}) input, + ) async { + final {{ modelName }}? {{ modelVariable }} = await {{ modelName }}.find(input.id); + if ({{ modelVariable }} == null) return null; + + {{ modelVariable }}.fill(input.fields, strict: true); + if (await {{ modelVariable }}.save()) { + // Every screen reading the cached row sees the edit without a refetch. + {{ repositoryName }}.instance.upsertFromShow({{ modelVariable }}); + return {{ modelVariable }}; + } + + throw ActionRequestFailed.refusalOf( + 'update ${input.id}', + {{ modelVariable }}.validationErrors, + ); + } +} diff --git a/assets/stubs/component_test.stub b/assets/stubs/component_test.stub new file mode 100644 index 00000000..c5cdc093 --- /dev/null +++ b/assets/stubs/component_test.stub @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/magic.dart'; +// Prefixed: a component may share its name with a Material widget (Badge, +// Card, Chip), and an unprefixed import would make every use ambiguous. +import 'package:{{ packageName }}/ui/components/{{ snakeName }}/index.dart' + as component; + +/// Widget test for [component.{{ className }}], scaffolded by `make:component`. +void main() { + /// Wraps [widget] in a [MaterialApp] with a default [WindTheme] so + /// W-widgets can resolve Wind styles without a running Magic app. + Widget wrap(Widget widget) { + return MaterialApp( + home: WindTheme(data: WindThemeData(), child: Scaffold(body: widget)), + ); + } + + testWidgets('{{ className }} renders its child', (tester) async { + await tester.pumpWidget( + wrap(component.{{ className }}(child: const Text('{{ className }}'))), + ); + + expect(find.text('{{ className }}'), findsOneWidget); + }); + + testWidgets("{{ className }} applies its recipe's base className", ( + tester, + ) async { + await tester.pumpWidget( + wrap(component.{{ className }}(child: const Text('{{ className }}'))), + ); + + final divs = tester.widgetList(find.byType(WDiv)); + expect( + divs.any((w) => w.className?.contains('flex') ?? false), + isTrue, + reason: "no WDiv carries {{ className }}Recipe()'s base className", + ); + }); +} diff --git a/assets/stubs/controller.resource.stub b/assets/stubs/controller.resource.stub index 0e099c46..dabdb94e 100644 --- a/assets/stubs/controller.resource.stub +++ b/assets/stubs/controller.resource.stub @@ -1,151 +1,15 @@ -import 'package:flutter/material.dart'; +import 'dart:async'; + import 'package:magic/magic.dart'; -// TODO: Import your views -// import '../../resources/views/{{ snakeName }}/index_view.dart'; -// import '../../resources/views/{{ snakeName }}/show_view.dart'; -// import '../../resources/views/{{ snakeName }}/create_view.dart'; -// import '../../resources/views/{{ snakeName }}/edit_view.dart'; +import '{{ modelImport }}'; +import '{{ repositoryImport }}'; /// {{ className }} Resource Controller. /// -/// Handles CRUD operations for {{ className }} resources. -class {{ className }}Controller extends MagicController - with MagicStateMixin>, ValidatesRequests { - /// Singleton accessor with lazy registration. - static {{ className }}Controller get instance => - Magic.findOrPut({{ className }}Controller.new); - - // --------------------------------------------------------------------------- - // Resource Actions (CRUD) - // --------------------------------------------------------------------------- - - /// GET /{{ snakeName }} — Display a listing of items. - Widget index() { - // if (data == null) load(); - // return const {{ className }}IndexView(); - return const Scaffold( - body: Center(child: Text('{{ className }} Index')), - ); - } - - /// GET /{{ snakeName }}/create — Show the form for creating a new item. - Widget create() { - // return const {{ className }}CreateView(); - return const Scaffold( - body: Center(child: Text('{{ className }} Create')), - ); - } - - /// GET /{{ snakeName }}/:id — Display the specified item. - Widget show(String id) { - // return const {{ className }}ShowView(); - return Scaffold( - body: Center(child: Text('{{ className }} #$id')), - ); - } - - /// GET /{{ snakeName }}/:id/edit — Show the form for editing the item. - Widget edit(String id) { - // return const {{ className }}EditView(); - return Scaffold( - body: Center(child: Text('Edit {{ className }} #$id')), - ); - } - - // --------------------------------------------------------------------------- - // Business Logic - // --------------------------------------------------------------------------- - - // Submit-button loading is form-scoped, NOT page-scoped. Drive it through - // the form's processingListenable + MagicBuilder so only the button rebuilds; - // a full-view setLoading() would tear down and remount sibling inputs, which - // also breaks dusk locators. The recommended view pattern: - // - // MagicBuilder( - // listenable: form.processingListenable, - // builder: (isProcessing) => WButton( - // isLoading: isProcessing, - // onTap: isProcessing ? null : () => form.process(() => controller.store(form.data)), - // child: WText(trans('common.save')), - // ), - // ) - // - // Reserve setLoading()/setSuccess()/setError() below for PAGE-level state - // (the index/show data fetch), not for the submit button. - - /// Load all items from the API. - Future load() async { - setLoading(); - - try { - // final items = await {{ className }}.all(); - // setSuccess(items); - setSuccess([]); - } catch (e, s) { - Log.error('Failed to load {{ snakeName }}s: $e\n$s', e); - setError(trans('errors.network_error')); - } - } - - /// Store a newly created item. - Future store(Map data) async { - setLoading(); - clearErrors(); - - final response = await Http.post('/{{ snakeName }}s', data: data); - - if (response.successful) { - Magic.toast(trans('{{ snakeName }}s.created_successfully')); - MagicRoute.to('/{{ snakeName }}s'); - return; - } - - handleApiError(response, fallback: trans('{{ snakeName }}s.create_failed')); - } - - /// Update the specified item. - Future update(String id, Map data) async { - setLoading(); - clearErrors(); - - final response = await Http.put('/{{ snakeName }}s/$id', data: data); - - if (response.successful) { - Magic.toast(trans('{{ snakeName }}s.updated_successfully')); - MagicRoute.to('/{{ snakeName }}s/$id'); - return; - } - - handleApiError(response, fallback: trans('{{ snakeName }}s.update_failed')); - } - - /// Remove the specified item. - Future destroy(String id) async { - final confirmed = await Magic.confirm( - title: trans('common.confirm'), - message: trans('{{ snakeName }}s.delete_confirm'), - confirmText: trans('common.delete'), - cancelText: trans('common.cancel'), - ); - - if (!confirmed) return; - - setLoading(); - - try { - final response = await Http.delete('/{{ snakeName }}s/$id'); - - if (response.successful) { - Magic.toast(trans('{{ snakeName }}s.deleted_successfully')); - MagicRoute.to('/{{ snakeName }}s'); - return; - } - - setError(trans('{{ snakeName }}s.delete_failed')); - } catch (e, s) { - Log.error('Failed to delete {{ snakeName }}: $e\n$s', e); - setError(trans('errors.network_error')); - } - } +/// Owns the read state for {{ className }} as one [RepositoryQuery] over +/// [{{ modelClassName }}Repository]; a write belongs on a `MagicAction`, not +/// on this controller. +class {{ className }}Controller {{ classHeader }} { +{{ classBody }} } diff --git a/assets/stubs/controller.stub b/assets/stubs/controller.stub index 7d4ddb7e..db54ab51 100644 --- a/assets/stubs/controller.stub +++ b/assets/stubs/controller.stub @@ -1,31 +1,6 @@ -import 'package:flutter/material.dart'; import 'package:magic/magic.dart'; -// TODO: Import your views -// import '../../resources/views/{{ snakeName }}/index_view.dart'; - /// {{ className }} Controller. -class {{ className }}Controller extends MagicController { - /// Singleton accessor with lazy registration. - static {{ className }}Controller get instance => - Magic.findOrPut({{ className }}Controller.new); - - // --------------------------------------------------------------------------- - // Actions (return Widget from resources/views) - // --------------------------------------------------------------------------- - - /// GET /{{ snakeName }} — Display the index view. - Widget index() { - // return const {{ className }}IndexView(); - return const Scaffold( - body: Center(child: Text('{{ className }} Index')), - ); - } - - /// GET /{{ snakeName }}/:id — Display a single item. - Widget show(String id) { - return Scaffold( - body: Center(child: Text('{{ className }} #$id')), - ); - } +class {{ className }}Controller {{ classHeader }} { +{{ classBody }} } diff --git a/assets/stubs/enum.wire.stub b/assets/stubs/enum.wire.stub new file mode 100644 index 00000000..409434e3 --- /dev/null +++ b/assets/stubs/enum.wire.stub @@ -0,0 +1,32 @@ +import 'package:magic/magic.dart'; + +/// {{ className }} enum. +/// +/// Mirrors the backend's `{{ snakeName }}` wire values. +enum {{ className }} { + /// Fallback for a wire value this enum does not recognise. + unknown('unknown'), + + /// Example case; replace with the real backend values. + example('example'); + + const {{ className }}(this.wire); + + /// The raw string value sent by the backend. + final String wire; + + /// Decodes a backend [value] into a [{{ className }}]. + /// + /// Never throws: an unrecognised or non-string [value] resolves to + /// [fallback] (default [{{ className }}.unknown]). + static {{ className }} fromWire(Object? value, {{{ className }} fallback = {{ className }}.unknown}) { + if (value is! String) return fallback; + return {{ className }}.values.firstWhere( + (e) => e.wire == value, + orElse: () => fallback, + ); + } + + /// Localized human-readable display label. + String get label => trans('enums.{{ snakeName }}.$name'); +} diff --git a/assets/stubs/form.resource.stub b/assets/stubs/form.resource.stub new file mode 100644 index 00000000..116bd5c5 --- /dev/null +++ b/assets/stubs/form.resource.stub @@ -0,0 +1,50 @@ +import 'package:magic/magic.dart'; + +{{ modelImport }} +{{ controllerImport }} +{{ createActionImport }} +{{ updateActionImport }} +{{ storeRequestImport }} +{{ updateRequestImport }} + +/// {{ className }} +/// +/// A form object for the {{ modelName }} create and edit screens (see +/// [MagicFormObject]). [editing] decides the mode: a blank form creates +/// through [Store{{ modelName }}Request] and `Create{{ modelName }}`, a form +/// over a {{ modelVariable }} edits through [Update{{ modelName }}Request] and +/// `Update{{ modelName }}`. A create reloads the `--resource` +/// [{{ modelName }}Controller] that `make:resource` writes beside it. +class {{ className }} extends MagicFormObject { + {{ className }}({this.editing}); + + /// The {{ modelName }} being edited, or null when this form creates one. + final {{ modelName }}? editing; + + @override + Map get initial => + editing?.toArray() ?? const {}; + + @override + FormRequest get request => editing == null + ? const Store{{ modelName }}Request() + : const Update{{ modelName }}Request(); + + @override + Future persist(Map validated) async { + final {{ modelName }}? editing = this.editing; + + if (editing == null) { + await MagicAction.resolve(Create{{ modelName }}.new).handle(validated); + // A new row has no place in the list's cursor yet, so re-read it. + await {{ modelName }}Controller.instance.reload(); + return true; + } + + final {{ modelName }}? saved = await MagicAction.resolve( + Update{{ modelName }}.new, + ).handle((id: '${editing.id}', fields: validated)); + + return saved != null; + } +} diff --git a/assets/stubs/model.stub b/assets/stubs/model.stub index 1ddc929c..4a4b1469 100644 --- a/assets/stubs/model.stub +++ b/assets/stubs/model.stub @@ -13,6 +13,9 @@ class {{ className }} extends Model with HasTimestamps, InteractsWithPersistence String get resource => '{{ resourceName }}'; /// The attributes that are mass assignable. + /// + /// A strict fill (mass assignment via [fill]) only writes keys listed here; + /// add every field the create/edit form posts or it is silently dropped. @override List get fillable => []; @@ -42,4 +45,13 @@ class {{ className }} extends Model with HasTimestamps, InteractsWithPersistence /// Get all {{ className }} records. static Future> all() => InteractsWithPersistence.allModels<{{ className }}>({{ className }}.new); + + /// Create a [{{ className }}] from a [Map]. + /// + /// Uses [setRawAttributes] to hydrate the model directly from raw API + /// data, bypassing mass-assignment protection (unlike [fillable], which + /// only takes effect through [fill]). The [exists] flag is set based on + /// whether the map contains an `id` key. + static {{ className }} fromMap(Map map) => + {{ className }}()..setRawAttributes(map, sync: true)..exists = map.containsKey('id'); } diff --git a/assets/stubs/request.stub b/assets/stubs/request.stub index de91d357..4eb2fdc3 100644 --- a/assets/stubs/request.stub +++ b/assets/stubs/request.stub @@ -1,26 +1,14 @@ import 'package:magic/magic.dart'; -/// {{ className }} -/// -/// Validates incoming data for the {{ actionDescription }} action. -class {{ className }} { - {{ className }}(this.data); - - /// The incoming request data. - final Map data; - - /// Validation rules applied to the incoming data. - Map> rules() { - return { - // 'name': [Required(), Min(2), Max(255)], - // 'email': [Required(), Email()], - }; - } - - /// Validate the request data. - /// - /// Returns a [Validator] instance for inspection. - Validator validate() { - return Validator.make(data, rules()); - } +/// Validates the incoming payload for the {{ actionDescription }} request. +class {{ className }} extends FormRequest { + /// Creates a [{{ className }}]. + const {{ className }}(); + + /// Validation rules applied to the prepared payload. + @override + Map> rules() => >{ + // 'name': [Required(), Max(200)], + // 'email': [Required(), Email()], + }; } diff --git a/assets/stubs/test.action.stub b/assets/stubs/test.action.stub new file mode 100644 index 00000000..bcdeb13e --- /dev/null +++ b/assets/stubs/test.action.stub @@ -0,0 +1,35 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/magic.dart'; + +import 'package:{{ packageName }}/{{ importPath }}'; + +void main() { + setUp(() { + MagicApp.reset(); + Magic.flush(); + Http.fake(); + }); + + tearDown(() { + MagicAction.flush(); + MagicApp.reset(); + Magic.flush(); + }); + + // Resolving rather than handling keeps this skeleton valid whatever the + // action's input type is; replace it with a real call below. + test('{{ className }} resolves', () { + expect(MagicAction.resolve({{ className }}.new), isA<{{ className }}>()); + }); + + // TODO: assert the network call {{ className }} makes, e.g.: + // test('sends the expected request', () async { + // final fake = Http.fake({ + // 'resource/path': Http.response({}), + // }); + // + // await const {{ className }}().handle(null); + // + // fake.assertSent((r) => r.method == 'POST' && r.url == '/resource/path'); + // }); +} diff --git a/assets/stubs/test.controller.stub b/assets/stubs/test.controller.stub new file mode 100644 index 00000000..be22201c --- /dev/null +++ b/assets/stubs/test.controller.stub @@ -0,0 +1,36 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/magic.dart'; + +import 'package:{{ packageName }}/{{ importPath }}'; + +void main() { + setUp(() { + MagicApp.reset(); + Magic.flush(); + Http.fake(); + }); + + tearDown(() { + MagicAction.flush(); + MagicApp.reset(); + Magic.flush(); + }); + + test('{{ className }}.instance registers and returns a singleton', () { + final {{ className }} first = {{ className }}.instance; + final {{ className }} second = {{ className }}.instance; + + expect(identical(first, second), isTrue); + }); + + // TODO: exercise {{ className }}'s actions against a faked backend, e.g.: + // test('does the thing', () async { + // final fake = Http.fake({ + // 'resource/path': Http.response({}), + // }); + // + // await {{ className }}.instance.someAction(); + // + // fake.assertSent((r) => r.method == 'POST' && r.url == '/resource/path'); + // }); +} diff --git a/assets/stubs/test.form.stub b/assets/stubs/test.form.stub new file mode 100644 index 00000000..4870f3bf --- /dev/null +++ b/assets/stubs/test.form.stub @@ -0,0 +1,37 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/magic.dart'; + +import 'package:{{ packageName }}/{{ importPath }}'; + +void main() { + setUp(() { + MagicApp.reset(); + Magic.flush(); + Http.fake(); + }); + + tearDown(() { + MagicAction.flush(); + MagicApp.reset(); + Magic.flush(); + }); + + test('{{ className }}.initial seeds the form fields', () { + final {{ className }} form = {{ className }}(); + addTearDown(form.dispose); + + expect(form.initial, isA>()); + }); + + // TODO: exercise {{ className }}.persist against a faked backend, e.g.: + // test('persists the validated payload', () async { + // final fake = Http.fake({ + // 'resource/path': Http.response({}), + // }); + // + // final saved = await form.persist({}); + // + // expect(saved, isTrue); + // fake.assertSent((r) => r.method == 'POST'); + // }); +} diff --git a/assets/stubs/test.repository.stub b/assets/stubs/test.repository.stub new file mode 100644 index 00000000..c29fd724 --- /dev/null +++ b/assets/stubs/test.repository.stub @@ -0,0 +1,28 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/magic.dart'; + +import 'package:{{ packageName }}/{{ importPath }}'; + +void main() { + setUp(() { + MagicApp.reset(); + Magic.flush(); + Http.fake(); + }); + + tearDown(() { + MagicApp.reset(); + Magic.flush(); + }); + + test('{{ className }}.instance registers and returns a singleton', () { + final {{ className }} first = {{ className }}.instance; + final {{ className }} second = {{ className }}.instance; + + expect(identical(first, second), isTrue); + }); + + test('resetForSession clears cached rows', () async { + await {{ className }}.instance.resetForSession(); + }); +} diff --git a/assets/stubs/test.request.stub b/assets/stubs/test.request.stub new file mode 100644 index 00000000..070b3a0f --- /dev/null +++ b/assets/stubs/test.request.stub @@ -0,0 +1,12 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/magic.dart'; + +import 'package:{{ packageName }}/{{ importPath }}'; + +void main() { + test('{{ className }}.rules() returns a rule map', () { + const {{ className }} request = {{ className }}(); + + expect(request.rules(), isA>>()); + }); +} diff --git a/assets/stubs/test.unit.stub b/assets/stubs/test.unit.stub new file mode 100644 index 00000000..d9369b59 --- /dev/null +++ b/assets/stubs/test.unit.stub @@ -0,0 +1,10 @@ +import 'package:flutter_test/flutter_test.dart'; + +// TODO: import the class under test +// import 'package:{{ packageName }}/...'; + +void main() { + test('{{ className }}', () { + // TODO: implement test + }); +} diff --git a/assets/stubs/test.view.stub b/assets/stubs/test.view.stub new file mode 100644 index 00000000..ed68766b --- /dev/null +++ b/assets/stubs/test.view.stub @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/magic.dart'; + +import 'package:{{ packageName }}/{{ importPath }}'; + +void main() { + testWidgets('{{ className }} builds without throwing', (tester) async { + await tester.pumpWidget( + MaterialApp( + home: WindTheme(data: WindThemeData(), child: const {{ className }}()), + ), + ); + + expect(find.byType({{ className }}), findsOneWidget); + }); +} diff --git a/assets/stubs/view.stateful.stub b/assets/stubs/view.stateful.stub index 911c504a..c13a14e6 100644 --- a/assets/stubs/view.stateful.stub +++ b/assets/stubs/view.stateful.stub @@ -1,54 +1,23 @@ import 'package:flutter/material.dart'; import 'package:magic/magic.dart'; -// TODO: Import your controller -// import '../../../app/controllers/{{ snakeName }}_controller.dart'; +import '{{ controllerImportPath }}';{{ formImportLine }} -/// {{ className }} View (stateful). -class {{ className }}View extends StatefulWidget { +/// {{ className }} View. +class {{ className }}View extends MagicStatefulView<{{ controllerClassName }}> { + /// Creates the [{{ className }}View]. const {{ className }}View({super.key}); @override State<{{ className }}View> createState() => _{{ className }}ViewState(); } -class _{{ className }}ViewState extends State<{{ className }}View> { - @override - void initState() { - super.initState(); - // Initialize resources here. - } - - @override - void dispose() { - // Clean up resources before disposal. - super.dispose(); - } - +/// State for [{{ className }}View]. +class _{{ className }}ViewState + extends MagicStatefulViewState<{{ controllerClassName }}, {{ className }}View>{{ mixinClause }} { +{{ stateBody }} @override Widget build(BuildContext context) { - // Form submit buttons should load through the form's processingListenable - // so only the button rebuilds, not the whole view. This keeps inputs and - // dusk locators stable across a submit. Recommended pattern: - // - // MagicForm( - // formData: form, - // child: Column( - // children: [ - // WFormInput(form: form, name: 'name'), - // MagicBuilder( - // listenable: form.processingListenable, - // builder: (isProcessing) => WButton( - // isLoading: isProcessing, - // onTap: isProcessing ? null : () => form.process(_submit), - // child: WText(trans('common.save')), - // ), - // ), - // ], - // ), - // ) - // - // Prefer this over driving the button through a full-view setState/setLoading. return WDiv( className: 'flex flex-col p-6', children: [ diff --git a/dart_test.yaml b/dart_test.yaml new file mode 100644 index 00000000..0f45a3b0 --- /dev/null +++ b/dart_test.yaml @@ -0,0 +1,3 @@ +tags: + integration: + skip: "run with --tags integration --run-skipped" diff --git a/doc/basics/actions.md b/doc/basics/actions.md index 93176716..f44df101 100644 --- a/doc/basics/actions.md +++ b/doc/basics/actions.md @@ -90,6 +90,24 @@ WButton( `common.error_occurred` ships in every fresh app's `assets/lang/en.json` (from the `magic:install` stub); override the key in your own catalogue to change the wording. +`ActionRequestFailed` is the exception a resource action's `handle` throws for a refused write that is not a 422: the backend answered non-2xx, or an ORM write's `save()`/`delete()` answered `false` with no field errors to raise as a `ValidationException`. Its static `ActionRequestFailed.refusalOf(action, errors, [response])` picks the right exception for you: a `ValidationException` holding the first message per field when `errors` is non-empty, otherwise an `ActionRequestFailed` carrying `response`. + +```dart +class PauseMonitor extends MagicAction { + const PauseMonitor(); + + @override + Future handle(String monitorId) async { + final response = await Http.update('monitors', monitorId, {'status': 'paused'}); + if (!response.successful) { + throw ActionRequestFailed.refusalOf('pause monitor', response.errors, response); + } + } +} +``` + +`ActionRequestFailed` exposes `statusCode` and `message` read off `response` (a message given through `ActionRequestFailed.withMessage` wins over the response's own), and `retryAfterSeconds`, read from a 429 body's `retry_after_seconds`, falling back to `1` when the body carries no usable value so a button waiting on it still recovers. + ## Swapping an Action in Tests diff --git a/doc/packages/magic-cli.md b/doc/packages/magic-cli.md index d6528930..b4403e5b 100644 --- a/doc/packages/magic-cli.md +++ b/doc/packages/magic-cli.md @@ -1,6 +1,6 @@ # Magic CLI -The Magic CLI is an `fluttersdk_artisan` plugin that ships as part of the magic package, providing `magic:install`, `key:generate`, and 14 `make:*` scaffold commands through magic's bundled `artisan` executable (`dart run magic:artisan`). +The Magic CLI is an `fluttersdk_artisan` plugin that ships as part of the magic package, providing `magic:install`, `key:generate`, and 20 `make:*` scaffold commands through magic's bundled `artisan` executable (`dart run magic:artisan`). - [Introduction](#introduction) - [Installation](#installation) @@ -8,9 +8,14 @@ The Magic CLI is an `fluttersdk_artisan` plugin that ships as part of the magic - [install](#install) - [key:generate](#keygenerate) - [Make Commands](#make-commands) + - [make:resource](#makeresource) - [make:model](#makemodel) - [make:controller](#makecontroller) - [make:view](#makeview) + - [make:repository](#makerepository) + - [make:action](#makeaction) + - [make:form](#makeform) + - [make:test](#maketest) - [make:migration](#makemigration) - [make:seeder](#makeseeder) - [make:factory](#makefactory) @@ -125,7 +130,41 @@ APP_KEY=base64:randomGeneratedKey... All `make:*` commands support the `--force` flag to overwrite existing files. Nested paths are supported via slash syntax (e.g., `Admin/Dashboard`), which creates subdirectories automatically. -Commands that auto-append a suffix (Controller, View, Factory, Seeder, Policy, ServiceProvider, Request) handle duplicates gracefully — `make:controller UserController` will not produce `UserControllerController`. +Commands that auto-append a suffix (Controller, View, Factory, Seeder, Policy, ServiceProvider, Request, Repository, FormObject) handle duplicates gracefully: `make:controller UserController` will not produce `UserControllerController`. + +`make:controller`, `make:view`, `make:repository`, `make:action`, `make:form` and `make:request` accept `--test`, which chains [make:test](#maketest) so the matching test lands in the same run. `--force` on the command forwards to the chained test, and a refused test write fails the command with exit 1. + + +### make:resource + +Scaffolds a full CRUD vertical for one model, magic's analogue of Laravel's `make:model --all`: + +```bash +dart run magic:artisan make:resource Monitor +dart run magic:artisan make:resource Monitor --no-views +dart run magic:artisan make:resource Monitor --no-model +dart run magic:artisan make:resource Monitor --force +``` + +For `Monitor` it writes, each through its own generator: + +- the model and its factory (kept when they already exist) +- `MonitorRepository` +- the `CreateMonitor`, `UpdateMonitor` and `DeleteMonitor` actions under `lib/app/actions/monitors/` +- `StoreMonitorRequest` and `UpdateMonitorRequest` +- `MonitorFormObject` (the `--resource` form) +- `MonitorController` (`--resource --actions`) +- the `MonitorsListView` and `MonitorFormView` views +- tests for the actions, the form and the controller + +The run is all-or-nothing: every target path is checked first, and any clash other than a kept model or factory fails the run with nothing written unless `--force` is passed. The index and create route lines are printed for your `RouteServiceProvider.boot()`; the command never edits it. + +#### Options + +| Option | Description | +|--------|-------------| +| `--no-views` | Stop after the data and write layers (no views, no printed routes) | +| `--no-model` | Leave the model and factory out | ### make:model @@ -149,55 +188,160 @@ dart run magic:artisan make:model Order --all | `--factory` | `-f` | Create a model factory | | `--seeder` | `-s` | Create a database seeder | | `--policy` | `-p` | Create an authorization policy | -| `--all` | `-a` | Create migration, seeder, factory, policy, and resource controller | +| `--all` | `-a` | Create migration, seeder, factory, policy, repository, and resource controller | > [!NOTE] -> The `-mcfsp` shorthand combines all five flags: migration, controller, factory, seeder, and policy. The `--all` flag does the same but also makes the controller a resource controller with CRUD methods. +> The `-mcfsp` shorthand combines all five flags: migration, controller, factory, seeder, and policy. The `--all` flag also writes the model's repository and makes the controller a `--resource --model=` controller reading through it. For the write layer and views as well, use [make:resource](#makeresource). + +The model carries a static `fromMap(Map)` that hydrates it from raw API data. The command exits 1 when the model already exists and `--force` was not passed, without generating any companion. **Output:** `lib/app/models/.dart` ### make:controller -Creates a controller class: +Creates a `MagicController` with a `Magic.findOrPut` singleton accessor that implements `SessionScoped`: ```bash dart run magic:artisan make:controller User dart run magic:artisan make:controller UserController dart run magic:artisan make:controller Admin/Dashboard dart run magic:artisan make:controller Post --resource -dart run magic:artisan make:controller Post --resource --model=Post +dart run magic:artisan make:controller Post --resource --model=Post --actions +dart run magic:artisan make:controller Uptime --broadcasts --timers --test ``` +A `--resource` controller owns the read state as one `RepositoryQuery` over `Repository`: it exposes `items`, `ensureFresh()` (for a view's `RefetchesOnMount`) and `reload()`, and its `resetForSession()` clears the repository and refetches. Writes belong on a `MagicAction`, not on the controller. + #### Options | Option | Shortcut | Description | |--------|----------|-------------| -| `--resource` | `-r` | Generate a resource controller with CRUD methods | -| `--model` | `-m` | The model the controller applies to | +| `--resource` | `-r` | Own a `RepositoryQuery` over the model's repository | +| `--model` | `-m` | The model a `--resource` controller reads (defaults to the controller's own name) | +| `--actions` | | Mix in `RunsActions` | +| `--broadcasts` | | Mix in `ListensToBroadcasts`, with an empty `listeners` map | +| `--timers` | | Mix in `OwnsTimers` | +| `--validates` | | Mix in `ValidatesRequests` and `CollapsesIndexedErrorKeys` | +| `--test` | | Also write the matching controller test | + +The mixins are always written in one fixed order, whatever order the flags were passed in. **Output:** `lib/app/controllers/_controller.dart` ### make:view -Creates a view class: +Creates a view class: a `StatelessWidget` by default, or a `MagicStatefulView` bound to a controller: ```bash dart run magic:artisan make:view Login dart run magic:artisan make:view LoginView dart run magic:artisan make:view Auth/Register dart run magic:artisan make:view Dashboard --stateful +dart run magic:artisan make:view Monitor --controller=Monitor +dart run magic:artisan make:view Monitors/List --controller=Monitor --list --form=MonitorFormObject ``` #### Options | Option | Description | |--------|-------------| -| `--stateful` | Generate a stateful view with lifecycle hooks | +| `--stateful` | Bind a `MagicStatefulView` to the controller derived from the view's name (`Dashboard` -> `DashboardController`), which has to exist | +| `--controller` | Bind the view to this controller (suffix optional); implies `--stateful` | +| `--list` | Add `RefetchesOnMount`; expects a `--resource` controller, which exposes `ensureFresh()` | +| `--form` | Add a State-owned form object field (suffix optional), disposed in `onClose`; implies `--stateful` | +| `--test` | Also write the matching view test | **Output:** `lib/resources/views/_view.dart` + +### make:repository + +Creates a `Repository` subclass with a static `instance`, the row cache every screen reading the model shares: + +```bash +dart run magic:artisan make:repository Monitor +dart run magic:artisan make:repository MonitorRepository --test +``` + +**Output:** `lib/app/repositories/_repository.dart` + + +### make:action + +Creates a `MagicAction`, the one place a write lives: + +```bash +dart run magic:artisan make:action PauseMonitor +dart run magic:artisan make:action Monitors/PauseMonitor --test +dart run magic:artisan make:action Monitors/CreateMonitor --kind=create --model=Monitor +dart run magic:artisan make:action Monitors/UpdateMonitor --kind=update --model=Monitor +dart run magic:artisan make:action Monitors/DeleteMonitor --kind=delete --model=Monitor +``` + +Without `--kind` the action is an empty `handle()` skeleton. The write kinds: + +- `create` fills and saves a new model from a validated field map. +- `update` takes `({String id, Map fields})`, saves the edit and writes it into `Repository`, answering null when the id no longer resolves. +- `delete` deletes the model and evicts it from `Repository`. + +A refused save throws `ActionRequestFailed` (a `ValidationException` when the model carries field errors). + +#### Options + +| Option | Description | +|--------|-------------| +| `--kind` | `create`, `update` or `delete`; requires `--model` | +| `--model` | The model the action writes | +| `--test` | Also write the matching action test | + +**Output:** `lib/app/actions/.dart` + + +### make:form + +Creates a `MagicFormObject` (the `FormObject` suffix is appended; form widgets keep the `Form` name): + +```bash +dart run magic:artisan make:form Monitor +dart run magic:artisan make:form Monitor --request=StoreMonitorRequest +dart run magic:artisan make:form Monitor --resource=Monitor +``` + +`--resource=` writes the full create/edit contract: an `editing` field, `initial` seeded from `editing?.toArray()`, a `request` choosing the Store or Update request, and a `persist` running `Create` or `Update`. After a create it reloads the `--resource` `Controller`, so it expects the controller [make:resource](#makeresource) writes beside it. + +#### Options + +| Option | Description | +|--------|-------------| +| `--request` | The `FormRequest` class the form validates against | +| `--resource` | The model the form creates and edits | +| `--test` | Also write the matching form test | + +**Output:** `lib/app/forms/_form_object.dart` + + +### make:test + +Creates a test skeleton mirroring where another generator writes its class, from `lib/` to `test/`: + +```bash +dart run magic:artisan make:test Monitor --kind=controller +dart run magic:artisan make:test Monitors/PauseMonitor --kind=action +dart run magic:artisan make:test Monitor --kind=repository --force +``` + +The test imports the class as `package:/...`, with `` read from the project's `pubspec.yaml`. + +#### Options + +| Option | Description | +|--------|-------------| +| `--kind` | `controller`, `action`, `form`, `repository`, `request`, `view` or `unit` | + +**Output:** for example `test/app/controllers/_controller_test.dart` + ### make:migration @@ -296,8 +440,11 @@ Creates a string-backed enum with `fromValue()` factory and `selectOptions` gett ```bash dart run magic:artisan make:enum MonitorType dart run magic:artisan make:enum Status/OrderStatus +dart run magic:artisan make:enum IncidentSeverity --wire ``` +`--wire` writes an enum mirroring a backend string value instead: an `unknown` fallback case, a `fromWire()` factory that never throws on an unrecognised value, and a `trans()`-backed `label` getter. + **Output:** `lib/app/enums/.dart` @@ -334,11 +481,11 @@ dart run magic:artisan make:listener Auth/RestoreSession ### make:request -Creates a form request class with a typed `rules()` method for request validation: +Creates a `FormRequest` subclass with a `const` constructor and a `rules()` override, which `MagicFormObject.request` and `ValidatesRequests.validateRequest` both accept: ```bash dart run magic:artisan make:request StoreMonitor -dart run magic:artisan make:request StoreMonitorRequest +dart run magic:artisan make:request StoreMonitorRequest --test ``` The `Request` suffix is appended automatically when omitted. @@ -353,35 +500,40 @@ Creates a language JSON file: ```bash dart run magic:artisan make:lang tr dart run magic:artisan make:lang es -dart run magic:artisan make:lang de +dart run magic:artisan make:lang de --from=tr ``` +`--from=` (default `en`) copies that file's key tree with each value verbatim, a complete catalogue for a translator; when it does not exist the new file is `{}`. + **Output:** `assets/lang/.json` ### make:component -Scaffolds an atomic 4-file component folder under `lib/ui/components//`: +Scaffolds an atomic component folder under `lib/ui/components//`, plus its widget test: ```bash dart run magic:artisan make:component Avatar dart run magic:artisan make:component Avatar --variants=intent,size dart run magic:artisan make:component Panel --slots +dart run magic:artisan make:component Badge --no-preview ``` **Output** (for `Avatar`): - `lib/ui/components/avatar/avatar.dart` (`class Avatar`, unprefixed PascalCase) - `lib/ui/components/avatar/avatar.recipe.dart` (a `WindRecipe`, or a `WindSlotRecipe` under `--slots`, seeded with the requested `--variants` axes) -- `lib/ui/components/avatar/avatar.preview.dart` (a single public `AvatarPreview` matrix) - `lib/ui/components/avatar/index.dart` (re-exports the component + recipe, NOT the preview) +- `test/ui/components/avatar/avatar_test.dart` (imports the barrel with a prefix, so a name like `Badge` stays unambiguous) +- `lib/ui/components/avatar/avatar.preview.dart` (a single public `AvatarPreview` matrix), only when the project already keeps a preview catalogue -After scaffolding, `make:component` chains `previews:refresh` so the new preview lands in `_previews.g.dart` automatically. +The preview file, and the chained `previews:refresh` that lands it in `_previews.g.dart`, are written only when the project already has a `*.preview.dart` file or a `_previews.g.dart` index under `lib/`. `--preview` or `--no-preview` overrides that detection. #### Options - `--variants=a,b`: seed the named variant axes into the recipe (values left empty to fill in). - `--slots`: scaffold a multi-part `WindSlotRecipe` instead of a single-element `WindRecipe`. +- `--preview` / `--no-preview`: force the preview file on or off. - `--force`: overwrite an existing component. diff --git a/lib/magic.dart b/lib/magic.dart index 7754fa15..1d7fc2e3 100644 --- a/lib/magic.dart +++ b/lib/magic.dart @@ -63,6 +63,7 @@ export 'src/support/url_generator.dart'; // Actions export 'src/actions/action_outcome.dart'; +export 'src/actions/action_request_failed.dart'; export 'src/actions/magic_action.dart'; export 'src/actions/runs_actions.dart'; diff --git a/lib/src/actions/action_request_failed.dart b/lib/src/actions/action_request_failed.dart new file mode 100644 index 00000000..2df094e0 --- /dev/null +++ b/lib/src/actions/action_request_failed.dart @@ -0,0 +1,82 @@ +import 'package:magic/src/network/magic_response.dart'; +import 'package:magic/src/support/cast.dart'; +import 'package:magic/src/validation/exceptions/validation_exception.dart'; + +/// Why a `MagicAction` refused to report success: the backend answered +/// non-2xx, or an ORM write answered `false` without the field errors a 422 +/// carries. +/// +/// A distinct type rather than a bare [Exception] because [RunsActions.runAction] +/// (and any controller catching a refusal directly) needs a single shape every +/// resource action can throw, whichever endpoint or ORM call answered it. +/// +/// Named apart from magic's `ActionFailed`, the `ActionOutcome` case that +/// carries one of these (or a [ValidationException]) as its `error`. +class ActionRequestFailed implements Exception { + /// The response that refused the write, or null when there was none: the + /// ORM consumed it internally (a `save()` or `delete()` that answered + /// `false`), or the message came from elsewhere (see + /// [ActionRequestFailed.withMessage]). + final MagicResponse? response; + + /// What was attempted, for the log line. + final String action; + + /// The message a refusal with no response to read carries directly. + final String? _messageOverride; + + /// Creates an [ActionRequestFailed] for [action] from a refusing [response]. + const ActionRequestFailed(this.action, [this.response]) + : _messageOverride = null; + + /// Creates an [ActionRequestFailed] for [action] carrying [message] + /// directly, for a refusal with no [MagicResponse] to read (a `save()` that + /// consumed its own response and answered `false`, whose field errors a + /// vertical with no error slot still wants to say). + const ActionRequestFailed.withMessage(this.action, String? message) + : response = null, + _messageOverride = message; + + /// The refusing response's status code, or null when there was none. + int? get statusCode => response?.statusCode; + + /// The backend's own message, or null when it sent none. A message given + /// through [ActionRequestFailed.withMessage] wins. + String? get message => _messageOverride ?? response?.errorMessage; + + /// Seconds until a refused request may run again, read from a 429 body's + /// `retry_after_seconds` (the manual-check cooldown), or 1 when the body + /// carries no usable value, so a button waiting on it still recovers rather + /// than staying disabled forever. + int get retryAfterSeconds { + final Object? data = response?.data; + if (data is! Map) return 1; + + return Cast.intOr(data['retry_after_seconds'], 1); + } + + /// The exception a refused write stands for: a [ValidationException] holding + /// the first message per field when [errors] carries any (a 422), otherwise + /// an [ActionRequestFailed] for [action] carrying [response]. + /// + /// [errors] is the raw wire map, read off an ORM write's own error-tracking + /// (whose `save()` consumes its own response, so [response] stays null) or + /// off `MagicResponse.errors` for a raw `Http` write. + static Exception refusalOf( + String action, + Map> errors, [ + MagicResponse? response, + ]) { + if (errors.isEmpty) return ActionRequestFailed(action, response); + + return ValidationException({ + for (final MapEntry> entry in errors.entries) + if (entry.value.isNotEmpty) entry.key: entry.value.first, + }); + } + + @override + String toString() => + 'ActionRequestFailed($action: ${statusCode ?? 'no response'} ' + '${message ?? ''})'; +} diff --git a/lib/src/cli/commands/make_action_command.dart b/lib/src/cli/commands/make_action_command.dart index 9454cbdc..e4eecc07 100644 --- a/lib/src/cli/commands/make_action_command.dart +++ b/lib/src/cli/commands/make_action_command.dart @@ -1,10 +1,15 @@ import 'package:fluttersdk_artisan/artisan.dart'; +import '../helpers/creates_matching_test.dart'; import '../helpers/magic_stub_loader.dart'; +import 'make_test_command.dart'; /// The `make:action` generator command. /// -/// Scaffolds a new [MagicAction] subclass inside `lib/app/actions/`. +/// Scaffolds a new [MagicAction] subclass inside `lib/app/actions/`. Plain +/// (no `--kind`) scaffolds the default stateless skeleton; `--kind` with +/// `--model` scaffolds one of the three write variants against that model +/// (see [_kinds]). /// /// ## Usage /// @@ -12,12 +17,26 @@ import '../helpers/magic_stub_loader.dart'; /// artisan make:action PauseMonitor # -> lib/app/actions/pause_monitor.dart /// artisan make:action Monitors/PauseMonitor # Nested path support /// artisan make:action PauseMonitor --force # Overwrite existing file +/// artisan make:action Monitors/CreateMonitor --kind=create --model=Monitor +/// artisan make:action Monitors/PauseMonitor --test # Also scaffold the test /// ``` -class MakeActionCommand extends ArtisanGeneratorCommand { +class MakeActionCommand extends ArtisanGeneratorCommand + with CreatesMatchingTest { /// Optional test root override: injected in tests to avoid touching the /// real filesystem. final String? _testRoot; + /// The write variants `--kind` accepts, each backed by its own + /// `action..stub`. + static const Set _kinds = {'create', 'update', 'delete'}; + + /// Captures the parsed `--kind` value during [handle] so [getStub] and + /// [getReplacements] can consume it without re-reading [ArtisanContext.input]. + String? _kindOption; + + /// Captures the parsed `--model` value during [handle]; see [_kindOption]. + String? _modelOption; + /// Creates a [MakeActionCommand]. /// /// Pass [testRoot] to pin the project root to a temp directory during tests. @@ -36,8 +55,98 @@ class MakeActionCommand extends ArtisanGeneratorCommand { String getDefaultNamespace() => 'lib/app/actions'; @override - String getStub() => MagicStubLoader.load('action'); + String getStub() { + final String? kind = _kindOption; + if (kind == null) return MagicStubLoader.load('action'); + return MagicStubLoader.load('action.$kind'); + } @override String getProjectRoot() => _testRoot ?? super.getProjectRoot(); + + @override + void configure(ArgParser parser) { + super.configure(parser); + parser.addOption( + 'kind', + help: 'The write variant to scaffold: ${_kinds.join('|')}.', + ); + parser.addOption( + 'model', + help: 'The model this action writes; required alongside --kind.', + ); + } + + @override + Future handle(ArtisanContext ctx) async { + // 1. Validate --kind against the known set, when given. + final String? kind = ctx.input.option('kind') as String?; + if (kind != null && !_kinds.contains(kind)) { + ctx.output.error( + 'Unknown --kind "$kind". Expected one of: ${_kinds.join(', ')}.', + ); + return 1; + } + + // 2. --kind always needs a target model to write against. + final String? model = ctx.input.option('model') as String?; + if (kind != null && (model == null || model.isEmpty)) { + ctx.output.error('--kind requires --model.'); + return 1; + } + + // 3. Stash both so getStub/getReplacements (called from buildClass, + // inside the generator's own handle below) can read them without + // re-parsing ctx.input. + _kindOption = kind; + _modelOption = model; + + final int code = await super.handle(ctx); + if (code != 0 || !ctx.input.hasOption('test')) return code; + + // 4. Chain the matching test onto a successful write. The action test + // stub only asserts the class resolves, so it compiles for every + // --kind as well as the untyped default. + final String name = ctx.input.argument(0)!; + return createMatchingTest(ctx, TestKind.action, name); + } + + @override + Map getReplacements(String name) { + final String? kind = _kindOption; + final String? model = _modelOption; + if (kind == null || model == null) return const {}; + + final parsed = StringHelper.parseName(name); + final String modelSnakeName = StringHelper.toSnakeCase(model); + final String modelVariable = StringHelper.toCamelCase(model); + final String prefix = _importPrefix(parsed.directory); + + final Map replacements = { + '{{ modelName }}': model, + '{{ modelVariable }}': modelVariable, + '{{ modelImport }}': "import '${prefix}models/$modelSnakeName.dart';", + }; + + // The update and delete actions keep the repository cache current. + if (kind == 'update' || kind == 'delete') { + replacements['{{ repositoryName }}'] = '${model}Repository'; + replacements['{{ repositoryImport }}'] = + "import '${prefix}repositories/${modelSnakeName}_repository.dart';"; + } + + if (kind == 'delete') { + replacements['{{ modelIdInterpolation }}'] = '\${$modelVariable.id}'; + } + + return replacements; + } + + /// The `../` prefix reaching `lib/app/` from a generated action file: one + /// level for [getDefaultNamespace]'s own `actions` segment, plus one more + /// per nested directory segment in [directory] (e.g. `monitors`). + String _importPrefix(String directory) { + final int depth = 1 + (directory.isEmpty ? 0 : directory.split('/').length); + return '../' * depth; + } } diff --git a/lib/src/cli/commands/make_component_command.dart b/lib/src/cli/commands/make_component_command.dart index 1da4748a..58089f97 100644 --- a/lib/src/cli/commands/make_component_command.dart +++ b/lib/src/cli/commands/make_component_command.dart @@ -1,13 +1,17 @@ +import 'dart:io'; + import 'package:fluttersdk_artisan/artisan.dart'; import 'package:path/path.dart' as path; import '../helpers/magic_stub_loader.dart'; +import '../helpers/run_child.dart'; import 'previews_refresh_command.dart'; -/// `make:component [--variants=intent,size] [--slots]`: scaffolds an -/// atomic 4-file component folder (`.dart`, `.recipe.dart`, -/// `.preview.dart`, `index.dart`) under `lib/ui/components//`, then -/// chains `previews:refresh` so the new preview lands in `_previews.g.dart`. +/// `make:component [--variants=intent,size] [--slots] [--preview|--no-preview]`: +/// scaffolds an atomic component folder (`.dart`, `.recipe.dart`, +/// `index.dart`, and conditionally `.preview.dart`) under +/// `lib/ui/components//`, plus its matching widget test at +/// `test/ui/components//_test.dart`. /// /// The component class is unprefixed PascalCase (`make:component Avatar` -> /// `class Avatar`); the folder + files are `lower_snake_case`. The recipe is @@ -15,6 +19,15 @@ import 'previews_refresh_command.dart'; /// author to fill with token classNames). `--slots` seeds a [WindSlotRecipe] /// shape instead of a single-element [WindRecipe]. /// +/// ## Preview auto-detection +/// +/// The preview file (and the chained `previews:refresh`) is only scaffolded +/// when the target project already maintains a preview catalogue: any +/// `*.preview.dart` file or a `_previews.g.dart` index anywhere under `lib/`. +/// `--preview` / `--no-preview` override the detection in either direction; +/// the flag wins because it was explicitly given, not merely because it +/// parsed truthy ([ArgvInput.hasOption] reports a negated flag as present). +/// /// Chaining follows the `make:model --all` pattern: a child command is parsed /// against its own [ArgParser] and handled with a bare context that reuses the /// parent output, so the operator sees one uninterrupted feedback stream. @@ -59,6 +72,12 @@ class MakeComponentCommand extends ArtisanGeneratorCommand { help: 'Scaffold a multi-part WindSlotRecipe instead of a single recipe.', negatable: false, ); + parser.addFlag( + 'preview', + help: + 'Force the preview file (and previews:refresh) on/off, overriding ' + 'catalogue auto-detection.', + ); // Test seam: point the stub loader at a checkout-local assets/stubs dir // without setting a process-wide env var. parser.addOption( @@ -94,6 +113,13 @@ class MakeComponentCommand extends ArtisanGeneratorCommand { return 1; } + // A kept test outlives a deleted component folder; it is somebody's work. + final testFile = _matchingTestPath(snakeName); + if (FileHelper.fileExists(testFile) && !ctx.input.hasOption('force')) { + ctx.output.error('Component test already exists at $testFile'); + return 1; + } + // 3. Parse the requested variant axes and the recipe shape (--slots). final variantAxes = _parseVariants(ctx.input.option('variants') as String?); final stubsDir = ctx.input.option('stubs-dir') as String?; @@ -108,8 +134,9 @@ class MakeComponentCommand extends ArtisanGeneratorCommand { '{{ defaultVariants }}': _renderDefaultVariants(variantAxes), }; - // 4. Write the four atomic files from their stubs. --slots swaps the - // single-element recipe + component for the WindSlotRecipe variants. + // 4. Write the three always-on atomic files from their stubs. --slots + // swaps the single-element recipe + component for the WindSlotRecipe + // variants. The preview is conditional (see step 5). _writeStub( slots ? 'component.slots' : 'component', '$snakeName.dart', @@ -124,13 +151,6 @@ class MakeComponentCommand extends ArtisanGeneratorCommand { replacements, stubsDir, ); - _writeStub( - 'preview', - '$snakeName.preview.dart', - componentDir, - replacements, - stubsDir, - ); _writeStub( 'component_index', 'index.dart', @@ -141,16 +161,123 @@ class MakeComponentCommand extends ArtisanGeneratorCommand { ctx.output.success('Created component: $componentDir'); - // 5. Chain previews:refresh so the new preview lands in _previews.g.dart. - await _runChild( - PreviewsRefreshCommand(projectRoot: getProjectRoot()), - const [], - ctx, - ); + // 5. Only scaffold the preview (and chain previews:refresh) when the + // project already maintains a preview catalogue, unless --preview / + // --no-preview explicitly overrides the detection. + final libDir = path.join(getProjectRoot(), 'lib'); + final previewGiven = ctx.input.hasOption('preview'); + final bool writePreview; + final String previewReason; + if (previewGiven) { + writePreview = ctx.input.option('preview') as bool; + previewReason = writePreview + ? '--preview forced it on' + : '--no-preview forced it off'; + } else { + writePreview = _hasPreviewCatalogue(libDir); + previewReason = writePreview + ? 'an existing *.preview.dart or _previews.g.dart catalogue was ' + 'found under lib/' + : 'no *.preview.dart or _previews.g.dart catalogue was found ' + 'under lib/'; + } + + if (writePreview) { + _writeStub( + 'preview', + '$snakeName.preview.dart', + componentDir, + replacements, + stubsDir, + ); + ctx.output.info('Preview: written ($previewReason)'); + await RunChild.run( + PreviewsRefreshCommand(projectRoot: getProjectRoot()), + const [], + ctx, + ); + } else { + ctx.output.info('Preview: skipped ($previewReason)'); + } + + // 6. Scaffold the matching widget test, unless the target project has no + // pubspec.yaml to resolve its package name from. + _writeMatchingTest(ctx, className, snakeName, stubsDir); return 0; } + /// Writes `test/ui/components//_test.dart` from + /// `component_test.stub`, importing the component through + /// `package:/ui/components//index.dart`. + /// + /// [packageName] is read from the target project's own `pubspec.yaml`; + /// when that file is absent, the test is skipped with a printed note + /// rather than failing the whole command (the component itself already + /// landed). + void _writeMatchingTest( + ArtisanContext ctx, + String className, + String snakeName, + String? stubsDir, + ) { + final pubspecPath = path.join(getProjectRoot(), 'pubspec.yaml'); + if (!FileHelper.fileExists(pubspecPath)) { + ctx.output.warning( + 'Skipped matching test: no pubspec.yaml found at $pubspecPath', + ); + return; + } + + final Object? packageName = FileHelper.readYamlFile(pubspecPath)['name']; + if (packageName is! String || packageName.isEmpty) { + ctx.output.warning( + 'Skipped matching test: pubspec.yaml at $pubspecPath declares no ' + 'package name', + ); + return; + } + + var content = stubsDir != null + ? MagicStubLoader.loadFrom('component_test', stubsDir) + : MagicStubLoader.load('component_test'); + content = content + .replaceAll('{{ className }}', className) + .replaceAll('{{ snakeName }}', snakeName) + .replaceAll('{{ packageName }}', packageName); + + final testPath = _matchingTestPath(snakeName); + FileHelper.writeFile(testPath, content); + ctx.output.success('Created: $testPath'); + } + + /// `test/ui/components//_test.dart` under the + /// project root. + String _matchingTestPath(String snakeName) => path.join( + getProjectRoot(), + 'test', + 'ui', + 'components', + snakeName, + '${snakeName}_test.dart', + ); + + /// Whether [libDir] already carries a preview catalogue: any + /// `*.preview.dart` file, or a `_previews.g.dart` index, anywhere in its + /// tree. + bool _hasPreviewCatalogue(String libDir) { + final dir = Directory(libDir); + if (!dir.existsSync()) return false; + for (final entity in dir.listSync(recursive: true)) { + if (entity is! File) continue; + final base = path.basename(entity.path); + if (base.endsWith('.preview.dart') || base == '_previews.g.dart') { + return true; + } + } + return false; + } + /// Loads [stubName], applies [replacements], and writes the rendered content /// to `/`. void _writeStub( @@ -218,19 +345,4 @@ class MakeComponentCommand extends ArtisanGeneratorCommand { buf.writeln(' },'); return buf.toString(); } - - /// Runs a sibling artisan command programmatically (the `make:model --all` - /// chaining pattern): parse [args] against the child's own [ArgParser], wrap - /// in an [ArgvInput], reuse the parent [ArtisanOutput] so the user sees one - /// uninterrupted feedback stream. - Future _runChild( - ArtisanCommand command, - List args, - ArtisanContext parentCtx, - ) async { - final parser = ArgParser(); - command.configure(parser); - final input = ArgvInput.parse(parser, args); - return command.handle(ArtisanContext.bare(input, parentCtx.output)); - } } diff --git a/lib/src/cli/commands/make_controller_command.dart b/lib/src/cli/commands/make_controller_command.dart index 79209e36..2f166ce4 100644 --- a/lib/src/cli/commands/make_controller_command.dart +++ b/lib/src/cli/commands/make_controller_command.dart @@ -1,27 +1,48 @@ import 'package:fluttersdk_artisan/artisan.dart'; +import 'package:path/path.dart' as path; +import '../helpers/creates_matching_test.dart'; import '../helpers/magic_stub_loader.dart'; +import 'make_test_command.dart'; /// The `make:controller` generator command. /// -/// Scaffolds a new MagicController class using the controller stub templates. +/// Scaffolds a state-owning `MagicController` implementing `SessionScoped`: +/// a plain controller by default, or (with `--resource`) a read controller +/// wrapping a `RepositoryQuery` over the target model's repository. +/// `--actions`, `--broadcasts`, `--timers` and `--validates` mix in +/// `RunsActions`, `ListensToBroadcasts`, `OwnsTimers` and +/// `ValidatesRequests`/`CollapsesIndexedErrorKeys` respectively, always in +/// that same order regardless of the order the flags were passed. /// /// ## Usage /// /// ```bash -/// artisan make:controller Monitor # → lib/app/controllers/monitor_controller.dart -/// artisan make:controller Admin/Dashboard # → lib/app/controllers/admin/dashboard_controller.dart -/// artisan make:controller Monitor --resource # → Resource controller with CRUD methods +/// artisan make:controller Monitor # plain controller +/// artisan make:controller Monitor --resource # reads MonitorRepository +/// artisan make:controller Monitor --resource --model=Ping # reads PingRepository +/// artisan make:controller Monitor --broadcasts --timers --actions --validates +/// artisan make:controller Admin/Dashboard --test # + matching test /// ``` /// /// The `Controller` suffix is appended automatically when omitted. -class MakeControllerCommand extends ArtisanGeneratorCommand { - /// Optional test root override — enables isolation in unit tests. +class MakeControllerCommand extends ArtisanGeneratorCommand + with CreatesMatchingTest { + /// Optional test root override; enables isolation in unit tests. final String? _testRoot; - /// Captures the parsed `--resource` flag at [handle] time so [getStub] can - /// honour it without re-reading the [ArtisanContext.input]. + /// Captures the parsed flags at [handle] time so [getStub] and + /// [getReplacements] can honour them without re-reading + /// [ArtisanContext.input]. bool _resourceFlag = false; + bool _actionsFlag = false; + bool _broadcastsFlag = false; + bool _timersFlag = false; + bool _validatesFlag = false; + + /// The `--model` option, or `null` when the caller left it to its default + /// (the controller's own base name); see [_modelClassName]. + String? _modelOption; /// Creates a [MakeControllerCommand]. /// @@ -45,20 +66,36 @@ class MakeControllerCommand extends ArtisanGeneratorCommand { @override void configure(ArgParser parser) { - // 1. Register --force (and base args) from parent first. + // 1. Register --force and --test (via CreatesMatchingTest) from the + // parent chain first. super.configure(parser); // 2. Add controller-specific flags. parser.addFlag( 'resource', abbr: 'r', - help: 'Generate a resource controller with CRUD methods', + help: + 'Generate a read controller owning a RepositoryQuery over the model', negatable: false, ); parser.addOption( 'model', abbr: 'm', - help: 'The model the controller applies to', + help: + 'The model a --resource controller reads ' + '(default: the controller name)', + ); + parser.addFlag('actions', help: 'Mix in RunsActions', negatable: false); + parser.addFlag( + 'broadcasts', + help: 'Mix in ListensToBroadcasts', + negatable: false, + ); + parser.addFlag('timers', help: 'Mix in OwnsTimers', negatable: false); + parser.addFlag( + 'validates', + help: 'Mix in ValidatesRequests and CollapsesIndexedErrorKeys', + negatable: false, ); } @@ -67,14 +104,39 @@ class MakeControllerCommand extends ArtisanGeneratorCommand { _resourceFlag ? 'controller.resource' : 'controller', ); - /// Provides extra placeholder replacements for the controller stub. + /// Provides the placeholder replacements the controller stubs need beyond + /// `{{ className }}` (already handled by the base class): the class + /// header, the class body, and (for `--resource`) the model's class + /// name and its two relative imports. /// - /// [name] is the BASE name without the `Controller` suffix - /// (e.g., `Monitor`, `Admin/Dashboard`). + /// [name] is the BASE name without the `Controller` suffix, matching what + /// [handle] passes to [buildClass]. @override Map getReplacements(String name) { - final parsed = StringHelper.parseName(name); - return {'{{ snakeName }}': StringHelper.toSnakeCase(parsed.className)}; + final className = StringHelper.parseName(name).className; + final replacements = { + '{{ classHeader }}': _buildClassHeader(), + '{{ classBody }}': _buildClassBody(className), + }; + + if (_resourceFlag) { + final directory = StringHelper.parseName(name).directory; + final modelName = _modelClassName(className); + final modelSnake = StringHelper.toSnakeCase(modelName); + replacements['{{ modelClassName }}'] = modelName; + replacements['{{ modelImport }}'] = _relativeImport( + directory, + 'lib/app/models', + modelSnake, + ); + replacements['{{ repositoryImport }}'] = _relativeImport( + directory, + 'lib/app/repositories', + '${modelSnake}_repository', + ); + } + + return replacements; } @override @@ -85,8 +147,13 @@ class MakeControllerCommand extends ArtisanGeneratorCommand { return 1; } - // 1. Capture --resource so [getStub] selects the right template. + // 1. Capture flags so getStub/getReplacements can honour them. _resourceFlag = ctx.input.hasOption('resource'); + _actionsFlag = ctx.input.hasOption('actions'); + _broadcastsFlag = ctx.input.hasOption('broadcasts'); + _timersFlag = ctx.input.hasOption('timers'); + _validatesFlag = ctx.input.hasOption('validates'); + _modelOption = ctx.input.option('model') as String?; // 2. Derive base name (no Controller suffix) and full name (with suffix). final baseName = _stripSuffix(rawName, 'Controller'); @@ -103,14 +170,168 @@ class MakeControllerCommand extends ArtisanGeneratorCommand { } // 5. Build stub content using the BASE name so {{ className }} resolves - // correctly — the stub appends "Controller" to the placeholder itself. + // correctly; the stub appends "Controller" to the placeholder itself. final content = buildClass(baseName); FileHelper.writeFile(filePath, content); ctx.output.success('Created: $filePath'); + + // 6. Chain make:test when --test was passed, mirroring this class's own + // (possibly nested) name. + if (ctx.input.hasOption('test')) { + return createMatchingTest(ctx, TestKind.controller, rawName); + } + return 0; } + /// Resolves the model class a `--resource` controller reads: `--model` + /// when given, otherwise the controller's own base [className]. + String _modelClassName(String className) => + (_modelOption != null && _modelOption!.isNotEmpty) + ? _modelOption! + : className; + + /// Builds the `extends ... [with ...] implements SessionScoped` clause. + /// + /// Mixins are selected in a FIXED order, never the order flags were + /// passed, so any combination of `--actions`/`--broadcasts`/`--timers`/ + /// `--validates` produces one stable header. `ValidatesRequests` must + /// precede `CollapsesIndexedErrorKeys` in the `with` list, since the + /// latter is constrained `on ValidatesRequests`. + String _buildClassHeader() { + final mixins = [ + if (_broadcastsFlag) 'ListensToBroadcasts', + if (_timersFlag) 'OwnsTimers', + if (_actionsFlag) 'RunsActions', + if (_validatesFlag) ...[ + 'ValidatesRequests', + 'CollapsesIndexedErrorKeys', + ], + ]; + + final buffer = StringBuffer('extends MagicController'); + if (mixins.isNotEmpty) { + buffer.write('\n with ${mixins.join(', ')}'); + } + buffer.write('\n implements SessionScoped'); + return buffer.toString(); + } + + /// Builds the class body: the singleton accessor, the `--resource` read + /// state (query, `items`, `onInit`/`onClose`, `ensureFresh`), the + /// `--broadcasts` `listeners` getter, and `resetForSession` last. + String _buildClassBody(String className) { + final controllerName = '${className}Controller'; + final modelName = _resourceFlag ? _modelClassName(className) : null; + + final parts = [ + _instanceAccessor(controllerName), + if (modelName != null) _resourceBlock(modelName), + if (_broadcastsFlag) _listenersGetter(), + _resetForSession(modelName == null ? null : '${modelName}Repository'), + ]; + return parts.join('\n\n'); + } + + String _instanceAccessor(String controllerName) => + ''' + /// Singleton accessor with lazy registration. + static $controllerName get instance => + Magic.findOrPut($controllerName.new);'''; + + /// The `--resource` read state: a [RepositoryQuery] over `Repository`, + /// the `items` it exposes, and the `onInit`/`ensureFresh`/`onClose` triad. + String _resourceBlock(String modelClassName) { + final repositoryName = '${modelClassName}Repository'; + return ''' + /// The read state for $modelClassName: the query owns the cursor and + /// filters, while the rows themselves are cached in [$repositoryName]. + final RepositoryQuery<$modelClassName> _query = + RepositoryQuery<$modelClassName>(repository: $repositoryName.instance); + + /// The rows fetched so far, in the order the server sent them. + List<$modelClassName> get items => _query.items; + + /// Starts the first read and forwards every later query change. + @override + void onInit() { + super.onInit(); + _query.addListener(refreshUI); + unawaited(_query.reload()); + } + + /// The read a newly mounted view should ask for: joins the initial load + /// while in flight, refetches once it has settled. + Future ensureFresh() => _query.ensureFresh(); + + /// Re-reads the first page, for a write that adds a row the current + /// cursor has no place for (a create). + Future reload() => _query.reload(); + + /// Releases the query before the mixins clean up what they own. + @override + void onClose() { + _query.dispose(); + super.onClose(); + }'''; + } + + /// The `--broadcasts` `listeners` getter [ListensToBroadcasts] requires, + /// left empty for the caller to fill in. + String _listenersGetter() => ''' + /// The broadcast events this controller applies itself; see + /// [ListensToBroadcasts]. + @override + Map get listeners => + { + // TODO: 'team:event.name': _onEventName, + };'''; + + /// Builds `resetForSession`: for a `--resource` controller, resets + /// [repositoryName] then reloads the query; otherwise a TODO stub, since a + /// plain controller has no cached state of its own to clear yet. + String _resetForSession(String? repositoryName) { + if (repositoryName == null) { + return ''' + /// Drops this controller's cached state for the session change + /// [SessionScoped] describes, then refetches for the identity that is now + /// authenticated. + @override + Future resetForSession() async { + // TODO: clear cached state, then refetch for the new session. + }'''; + } + + return ''' + /// Drops the previous session's rows via [$repositoryName], publishes the + /// cleared state, then refetches for the identity that is now + /// authenticated. + @override + Future resetForSession() async { + await $repositoryName.instance.resetForSession(); + refreshUI(); + await _query.reload(); + }'''; + } + + /// Computes the `import '...'` path from `lib/app/controllers//` + /// to `/.dart`, POSIX-separated regardless of + /// host OS (an import statement is never OS-specific). + String _relativeImport( + String directory, + String targetNamespace, + String fileStem, + ) { + final fromDir = directory.isEmpty + ? 'lib/app/controllers' + : 'lib/app/controllers/$directory'; + return path.posix.relative( + '$targetNamespace/$fileStem.dart', + from: fromDir, + ); + } + /// Returns [name] with [suffix] appended to the last path segment if absent. String _withSuffix(String name, String suffix) { final parts = name.split('/'); diff --git a/lib/src/cli/commands/make_enum_command.dart b/lib/src/cli/commands/make_enum_command.dart index b9f0f4ac..b996d826 100644 --- a/lib/src/cli/commands/make_enum_command.dart +++ b/lib/src/cli/commands/make_enum_command.dart @@ -11,13 +11,30 @@ import '../helpers/magic_stub_loader.dart'; /// ```bash /// artisan make:enum MonitorType /// artisan make:enum Status/OrderStatus +/// artisan make:enum IncidentSeverity --wire # Wire-backed enum /// ``` /// /// ## Output /// /// Creates a file in `lib/app/enums/` with value/label pattern, -/// `fromValue()` factory, and `selectOptions` getter. +/// `fromValue()` factory, and `selectOptions` getter. With `--wire`, creates +/// a wire-backed enum instead: an `unknown` fallback case, a `fromWire()` +/// factory that never throws on an unrecognised value, and a `trans()`-backed +/// `label` getter. class MakeEnumCommand extends ArtisanGeneratorCommand { + /// Optional test root override: injected in tests to avoid touching the + /// real filesystem. + final String? _testRoot; + + /// Captures the parsed `--wire` flag at [handle] time so [getStub] can + /// honour it without re-reading the [ArtisanContext.input]. + bool _wireFlag = false; + + /// Creates a [MakeEnumCommand]. + /// + /// Pass [testRoot] to pin the project root to a temp directory during tests. + MakeEnumCommand({String? testRoot}) : _testRoot = testRoot; + @override CommandBoot get boot => CommandBoot.none; @@ -31,7 +48,20 @@ class MakeEnumCommand extends ArtisanGeneratorCommand { String getDefaultNamespace() => 'lib/app/enums'; @override - String getStub() => MagicStubLoader.load('enum'); + String getProjectRoot() => _testRoot ?? super.getProjectRoot(); + + @override + void configure(ArgParser parser) { + super.configure(parser); + parser.addFlag( + 'wire', + help: 'Generate a wire-backed enum mirroring a backend string value', + negatable: false, + ); + } + + @override + String getStub() => MagicStubLoader.load(_wireFlag ? 'enum.wire' : 'enum'); /// Returns placeholder replacements for the enum stub. /// @@ -45,4 +75,11 @@ class MakeEnumCommand extends ArtisanGeneratorCommand { '{{ snakeName }}': StringHelper.toSnakeCase(parsed.className), }; } + + @override + Future handle(ArtisanContext ctx) async { + // 1. Capture --wire so getStub() selects the right template. + _wireFlag = ctx.input.hasOption('wire'); + return super.handle(ctx); + } } diff --git a/lib/src/cli/commands/make_event_command.dart b/lib/src/cli/commands/make_event_command.dart index 7f195c36..e6154e95 100644 --- a/lib/src/cli/commands/make_event_command.dart +++ b/lib/src/cli/commands/make_event_command.dart @@ -18,6 +18,15 @@ import '../helpers/magic_stub_loader.dart'; /// Creates a file in `lib/app/events/` with a dispatchable event class /// that extends `MagicEvent`. class MakeEventCommand extends ArtisanGeneratorCommand { + /// Optional project root override, injected in tests to avoid touching the + /// real filesystem. + final String? _testRoot; + + /// Creates a [MakeEventCommand]. + /// + /// Pass [testRoot] to pin the project root to a temp directory during tests. + MakeEventCommand({String? testRoot}) : _testRoot = testRoot; + @override CommandBoot get boot => CommandBoot.none; @@ -33,6 +42,9 @@ class MakeEventCommand extends ArtisanGeneratorCommand { @override String getStub() => MagicStubLoader.load('event'); + @override + String getProjectRoot() => _testRoot ?? super.getProjectRoot(); + /// Returns placeholder replacements for the event stub. /// /// Replaces `{{ className }}`, `{{ snakeName }}`, and `{{ description }}` diff --git a/lib/src/cli/commands/make_form_command.dart b/lib/src/cli/commands/make_form_command.dart index 2d9684bb..f3b65d13 100644 --- a/lib/src/cli/commands/make_form_command.dart +++ b/lib/src/cli/commands/make_form_command.dart @@ -1,24 +1,33 @@ import 'package:fluttersdk_artisan/artisan.dart'; import 'package:path/path.dart' as path; +import '../helpers/creates_matching_test.dart'; import '../helpers/magic_stub_loader.dart'; +import 'make_test_command.dart'; /// The `make:form` generator command. /// /// Scaffolds a new [MagicFormObject] subclass inside `lib/app/forms/`. The /// `FormObject` suffix is deliberate: apps already name form WIDGETS -/// `Form` (uptizm's `MonitorForm` among them), so the object that +/// `Form` (a `MonitorForm` widget, say), so the object that /// backs one needs a distinct name. /// +/// `--resource=` scaffolds the full create/edit contract instead of +/// the plain skeleton: an `editing` field, `initial` seeded from +/// `editing?.toArray()`, `request` choosing between the model's Store/Update +/// requests, and `persist` running the matching Create/Update action. +/// /// ## Usage /// /// ```bash /// artisan make:form Monitor # -> MonitorFormObject /// artisan make:form MonitorFormObject # Suffix already present /// artisan make:form Monitor --request=StoreMonitorRequest +/// artisan make:form Monitor --resource=Monitor # Full create/edit contract /// artisan make:form Monitor --force # Overwrite existing file +/// artisan make:form Monitor --test # Also scaffold the test /// ``` -class MakeFormCommand extends ArtisanGeneratorCommand { +class MakeFormCommand extends ArtisanGeneratorCommand with CreatesMatchingTest { /// Optional test root override: injected in tests to avoid touching the /// real filesystem. final String? _testRoot; @@ -27,6 +36,10 @@ class MakeFormCommand extends ArtisanGeneratorCommand { /// [getReplacements] can consume it without re-reading [ArtisanContext.input]. String? _requestOption; + /// Captures the parsed `--resource` value during [handle]; see + /// [_requestOption]. + String? _resourceOption; + /// Creates a [MakeFormCommand]. /// /// Pass [testRoot] to pin the project root to a temp directory during tests. @@ -45,7 +58,10 @@ class MakeFormCommand extends ArtisanGeneratorCommand { String getDefaultNamespace() => 'lib/app/forms'; @override - String getStub() => MagicStubLoader.load('form'); + String getStub() { + if (_resourceOption != null) return MagicStubLoader.load('form.resource'); + return MagicStubLoader.load('form'); + } @override String getProjectRoot() => _testRoot ?? super.getProjectRoot(); @@ -57,14 +73,27 @@ class MakeFormCommand extends ArtisanGeneratorCommand { 'request', help: 'The FormRequest class this form validates against', ); + parser.addOption( + 'resource', + help: + 'The model this form creates/edits; scaffolds the full ' + 'editing + Store/Update + persist contract.', + ); } @override Future handle(ArtisanContext ctx) async { - // 1. Capture --request so [getReplacements] (called from [buildClass]) - // can use it without re-reading the context. + // 1. Capture --request/--resource so [getReplacements] (called from + // [buildClass]) can use them without re-reading the context. _requestOption = ctx.input.option('request') as String?; - return super.handle(ctx); + _resourceOption = ctx.input.option('resource') as String?; + + final int code = await super.handle(ctx); + if (code != 0 || !ctx.input.hasOption('test')) return code; + + // 2. Chain the matching test onto a successful write. + final String name = ctx.input.argument(0)!; + return createMatchingTest(ctx, TestKind.form, name); } /// Normalises [name] so the last path segment always carries the @@ -117,6 +146,9 @@ class MakeFormCommand extends ArtisanGeneratorCommand { @override Map getReplacements(String name) { + final String? resource = _resourceOption; + if (resource != null) return _resourceReplacements(name, resource); + // [name] is already normalised (FormObject-suffixed) at this point. final className = StringHelper.parseName(name).className; final modelName = className.replaceAll('FormObject', ''); @@ -130,6 +162,41 @@ class MakeFormCommand extends ArtisanGeneratorCommand { }; } + /// Replacements for the `--resource` variant (`form.resource.stub`): the + /// `editing` field plus the Store/Update request and Create/Update action + /// imports, all relative to [name]'s own nesting under `lib/app/forms`. + Map _resourceReplacements(String name, String resource) { + final parsed = StringHelper.parseName(name); + final String modelSnakeName = StringHelper.toSnakeCase(resource); + final String modelVariable = StringHelper.toCamelCase(resource); + final String pluralSnakeName = StringHelper.toPlural(modelSnakeName); + final String prefix = _importPrefix(parsed.directory); + + return { + '{{ modelName }}': resource, + '{{ modelVariable }}': modelVariable, + '{{ modelImport }}': "import '${prefix}models/$modelSnakeName.dart';", + '{{ controllerImport }}': + "import '${prefix}controllers/${modelSnakeName}_controller.dart';", + '{{ createActionImport }}': + "import '${prefix}actions/$pluralSnakeName/create_$modelSnakeName.dart';", + '{{ updateActionImport }}': + "import '${prefix}actions/$pluralSnakeName/update_$modelSnakeName.dart';", + '{{ storeRequestImport }}': + "import '${prefix}validation/requests/store_${modelSnakeName}_request.dart';", + '{{ updateRequestImport }}': + "import '${prefix}validation/requests/update_${modelSnakeName}_request.dart';", + }; + } + + /// The `../` prefix reaching `lib/app/` from a generated form file: one + /// level for [getDefaultNamespace]'s own `forms` segment, plus one more per + /// nested directory segment in [directory]. + String _importPrefix(String directory) { + final int depth = 1 + (directory.isEmpty ? 0 : directory.split('/').length); + return '../' * depth; + } + /// Renders the request import line: a real import for the class named via /// `--request`, or a TODO placeholder pointing at the conventional /// `validation/requests/` path when the flag was not supplied. diff --git a/lib/src/cli/commands/make_lang_command.dart b/lib/src/cli/commands/make_lang_command.dart index ab42ca6a..41fbbc5f 100644 --- a/lib/src/cli/commands/make_lang_command.dart +++ b/lib/src/cli/commands/make_lang_command.dart @@ -1,4 +1,8 @@ +import 'dart:convert'; +import 'dart:io'; + import 'package:fluttersdk_artisan/artisan.dart'; +import 'package:path/path.dart' as p; import '../helpers/magic_stub_loader.dart'; @@ -12,13 +16,26 @@ import '../helpers/magic_stub_loader.dart'; /// ```bash /// artisan make:lang tr /// artisan make:lang en +/// artisan make:lang tr --from=en # copy en.json's key tree into tr.json +/// artisan make:lang tr --force # Overwrite existing file /// ``` /// /// ## Output /// -/// Creates `assets/lang/{code}.json` containing `{}` — an empty translation map -/// ready to populate. +/// When `assets/lang/.json` exists (`--from` defaults to `en`), writes +/// `assets/lang/{code}.json` with the SAME key tree, each leaf copied +/// verbatim from the source so the catalogue is complete and ready for a +/// human translator. Otherwise writes an empty translation map (`{}`). class MakeLangCommand extends ArtisanGeneratorCommand { + /// Optional test root override: injected in tests to avoid touching the + /// real filesystem. + final String? _testRoot; + + /// Creates a [MakeLangCommand]. + /// + /// Pass [testRoot] to pin the project root to a temp directory during tests. + MakeLangCommand({String? testRoot}) : _testRoot = testRoot; + @override CommandBoot get boot => CommandBoot.none; @@ -34,10 +51,13 @@ class MakeLangCommand extends ArtisanGeneratorCommand { @override String getStub() => MagicStubLoader.load('lang'); + @override + String getProjectRoot() => _testRoot ?? super.getProjectRoot(); + /// Overrides to produce a `.json` path instead of the default `.dart`. /// /// The [name] is a language code (e.g., `tr`, `en`). The file is placed - /// directly inside [getDefaultNamespace] — no nested path support needed. + /// directly inside [getDefaultNamespace]; no nested path support needed. @override String getPath(String name) { final projectRoot = getProjectRoot(); @@ -46,7 +66,72 @@ class MakeLangCommand extends ArtisanGeneratorCommand { return '$projectRoot/$namespace/$name.json'; } - /// No placeholder replacements — the lang stub is already valid JSON (`{}`). + /// No placeholder replacements; the lang stub is already valid JSON (`{}`). @override Map getReplacements(String name) => const {}; + + @override + void configure(ArgParser parser) { + super.configure(parser); + parser.addOption( + 'from', + defaultsTo: 'en', + help: "Source locale to copy the key tree from (defaults to 'en').", + ); + } + + @override + Future handle(ArtisanContext ctx) async { + final name = ctx.input.argument(0); + if (name == null || name.isEmpty) { + ctx.output.error('Not enough arguments (missing: "name").'); + return 1; + } + + final filePath = getPath(name); + if (FileHelper.fileExists(filePath) && !ctx.input.hasOption('force')) { + ctx.output.error('File already exists at $filePath'); + return 1; + } + + final from = ctx.input.option('from') as String? ?? 'en'; + final String content; + try { + content = _buildContent(from); + } on FormatException catch (error) { + ctx.output.error( + 'Cannot copy $from.json: it is not valid JSON (${error.message})', + ); + return 1; + } + FileHelper.writeFile(filePath, content); + ctx.output.success('Created: $filePath'); + return 0; + } + + /// Builds the JSON content for the new locale. + /// + /// Copies [from]'s key tree verbatim (each leaf's value included) when + /// `assets/lang/.json` exists on disk and decodes to a JSON object; + /// otherwise falls back to an empty map. Output is two-space indented. + /// + /// Throws a [FormatException] when the source file is not valid JSON. + String _buildContent(String from) { + final sourcePath = p.join( + getProjectRoot(), + getDefaultNamespace(), + '$from.json', + ); + final sourceFile = File(sourcePath); + if (!sourceFile.existsSync()) { + return '{}'; + } + + final decoded = jsonDecode(sourceFile.readAsStringSync()); + if (decoded is! Map || decoded.isEmpty) { + return '{}'; + } + + return const JsonEncoder.withIndent(' ').convert(decoded); + } } diff --git a/lib/src/cli/commands/make_listener_command.dart b/lib/src/cli/commands/make_listener_command.dart index ccb39ac5..7f27473c 100644 --- a/lib/src/cli/commands/make_listener_command.dart +++ b/lib/src/cli/commands/make_listener_command.dart @@ -23,6 +23,15 @@ class MakeListenerCommand extends ArtisanGeneratorCommand { /// can consume it without re-reading the [ArtisanContext.input]. String? _eventOption; + /// Optional project root override, injected in tests to avoid touching the + /// real filesystem. + final String? _testRoot; + + /// Creates a [MakeListenerCommand]. + /// + /// Pass [testRoot] to pin the project root to a temp directory during tests. + MakeListenerCommand({String? testRoot}) : _testRoot = testRoot; + @override CommandBoot get boot => CommandBoot.none; @@ -38,6 +47,9 @@ class MakeListenerCommand extends ArtisanGeneratorCommand { @override String getStub() => MagicStubLoader.load('listener'); + @override + String getProjectRoot() => _testRoot ?? super.getProjectRoot(); + /// Registers the `--event` option in addition to the inherited `--force` flag. @override void configure(ArgParser parser) { @@ -71,7 +83,7 @@ class MakeListenerCommand extends ArtisanGeneratorCommand { final eventSnakeName = StringHelper.toSnakeCase(eventClass); // 2. When no custom event class is given, strip the local import line - // entirely — MagicEvent is already exported by the framework package. + // entirely; MagicEvent is already exported by the framework package. final eventImportLine = eventClass == 'MagicEvent' ? '' : "import '../events/$eventSnakeName.dart';"; diff --git a/lib/src/cli/commands/make_middleware_command.dart b/lib/src/cli/commands/make_middleware_command.dart index 12ac6f16..c5bddf36 100644 --- a/lib/src/cli/commands/make_middleware_command.dart +++ b/lib/src/cli/commands/make_middleware_command.dart @@ -17,6 +17,15 @@ import '../helpers/magic_stub_loader.dart'; /// /// Creates a file in `lib/app/middleware/` with full nested path support. class MakeMiddlewareCommand extends ArtisanGeneratorCommand { + /// Optional project root override, injected in tests to avoid touching the + /// real filesystem. + final String? _testRoot; + + /// Creates a [MakeMiddlewareCommand]. + /// + /// Pass [testRoot] to pin the project root to a temp directory during tests. + MakeMiddlewareCommand({String? testRoot}) : _testRoot = testRoot; + @override CommandBoot get boot => CommandBoot.none; @@ -32,6 +41,9 @@ class MakeMiddlewareCommand extends ArtisanGeneratorCommand { @override String getStub() => MagicStubLoader.load('middleware'); + @override + String getProjectRoot() => _testRoot ?? super.getProjectRoot(); + /// Returns placeholder replacements for the middleware stub. /// /// Replaces `{{ className }}` and `{{ snakeName }}` from the parsed name. diff --git a/lib/src/cli/commands/make_model_command.dart b/lib/src/cli/commands/make_model_command.dart index bdd05129..89146a90 100644 --- a/lib/src/cli/commands/make_model_command.dart +++ b/lib/src/cli/commands/make_model_command.dart @@ -1,11 +1,13 @@ import 'package:fluttersdk_artisan/artisan.dart'; import '../helpers/magic_stub_loader.dart'; +import '../helpers/run_child.dart'; import 'make_controller_command.dart'; import 'make_factory_command.dart'; import 'make_migration_command.dart'; import 'make_policy_command.dart'; +import 'make_repository_command.dart'; import 'make_seeder_command.dart'; /// The `make:model` generator command. @@ -22,7 +24,7 @@ import 'make_seeder_command.dart'; /// artisan make:model Monitor --all /// ``` class MakeModelCommand extends ArtisanGeneratorCommand { - /// Optional test root override — enables isolation in unit tests. + /// Optional test root override; enables isolation in unit tests. final String? _testRoot; /// Creates a [MakeModelCommand]. @@ -116,13 +118,14 @@ class MakeModelCommand extends ArtisanGeneratorCommand { // 1. Generate the model class itself. final filePath = getPath(name); + // An existing model without --force stops the run, as every other + // generator does, so no companion file is written against it. if (FileHelper.fileExists(filePath) && !ctx.input.hasOption('force')) { ctx.output.error('File already exists at $filePath'); - } else { - final content = buildClass(name); - FileHelper.writeFile(filePath, content); - ctx.output.success('Created: $filePath'); + return 1; } + FileHelper.writeFile(filePath, buildClass(name)); + ctx.output.success('Created: $filePath'); // 2. Determine whether --all was passed. final doAll = ctx.input.hasOption('all'); @@ -135,7 +138,7 @@ class MakeModelCommand extends ArtisanGeneratorCommand { final tableName = StringHelper.toPlural( StringHelper.toSnakeCase(className), ); - await _runChild(MakeMigrationCommand(testRoot: _testRoot), [ + await RunChild.run(MakeMigrationCommand(testRoot: _testRoot), [ 'create_${tableName}_table', '--create=$tableName', ], ctx); @@ -143,29 +146,43 @@ class MakeModelCommand extends ArtisanGeneratorCommand { // 4. Generate Factory. if (doAll || ctx.input.hasOption('factory')) { - await _runChild(MakeFactoryCommand(testRoot: _testRoot), [ + await RunChild.run(MakeFactoryCommand(testRoot: _testRoot), [ className, ], ctx); } // 5. Generate Seeder. if (doAll || ctx.input.hasOption('seeder')) { - await _runChild(MakeSeederCommand(testRoot: _testRoot), [className], ctx); + await RunChild.run(MakeSeederCommand(testRoot: _testRoot), [ + className, + ], ctx); } // 6. Generate Policy. if (doAll || ctx.input.hasOption('policy')) { - await _runChild(MakePolicyCommand(testRoot: _testRoot), [ + await RunChild.run(MakePolicyCommand(testRoot: _testRoot), [ className, '--model=$className', ], ctx); } - // 7. Generate Controller. + // 7. Generate Repository (only for --all: a --resource controller reads + // through one, so it must exist before the controller is chained). + if (doAll) { + await RunChild.run(MakeRepositoryCommand(testRoot: _testRoot), [ + className, + ], ctx); + } + + // 8. Generate Controller. if (doAll || ctx.input.hasOption('controller')) { final controllerArgs = [className]; - if (doAll) controllerArgs.add('--resource'); - await _runChild( + if (doAll) { + controllerArgs + ..add('--resource') + ..add('--model=$className'); + } + await RunChild.run( MakeControllerCommand(testRoot: _testRoot), controllerArgs, ctx, @@ -174,23 +191,4 @@ class MakeModelCommand extends ArtisanGeneratorCommand { return 0; } - - /// Runs a sibling artisan command programmatically. - /// - /// Parses [args] against the child's own [ArgParser], wraps the result in an - /// [ArgvInput], and reuses the parent's [ArtisanOutput] so the user sees a - /// single uninterrupted stream of feedback. - /// - /// The child runs in a [ArtisanContext.bare] — chained `make:*` commands - /// never need a VM Service connection. - Future _runChild( - ArtisanCommand command, - List args, - ArtisanContext parentCtx, - ) async { - final parser = ArgParser(); - command.configure(parser); - final input = ArgvInput.parse(parser, args); - return command.handle(ArtisanContext.bare(input, parentCtx.output)); - } } diff --git a/lib/src/cli/commands/make_provider_command.dart b/lib/src/cli/commands/make_provider_command.dart index 98944cef..cb35a6ce 100644 --- a/lib/src/cli/commands/make_provider_command.dart +++ b/lib/src/cli/commands/make_provider_command.dart @@ -19,6 +19,15 @@ import '../helpers/magic_stub_loader.dart'; /// /// Creates a file in `lib/app/providers/` with `register()` and `boot()` stubs. class MakeProviderCommand extends ArtisanGeneratorCommand { + /// Optional project root override, injected in tests to avoid touching the + /// real filesystem. + final String? _testRoot; + + /// Creates a [MakeProviderCommand]. + /// + /// Pass [testRoot] to pin the project root to a temp directory during tests. + MakeProviderCommand({String? testRoot}) : _testRoot = testRoot; + @override CommandBoot get boot => CommandBoot.none; @@ -34,6 +43,9 @@ class MakeProviderCommand extends ArtisanGeneratorCommand { @override String getStub() => MagicStubLoader.load('provider'); + @override + String getProjectRoot() => _testRoot ?? super.getProjectRoot(); + /// Normalises [name] so the last path segment always carries the /// `ServiceProvider` suffix. Used by both [getPath] and [buildClass] to keep /// the class identifier, file name, and stub substitutions in sync. diff --git a/lib/src/cli/commands/make_repository_command.dart b/lib/src/cli/commands/make_repository_command.dart index 3aac2c80..6a9472d6 100644 --- a/lib/src/cli/commands/make_repository_command.dart +++ b/lib/src/cli/commands/make_repository_command.dart @@ -1,7 +1,9 @@ import 'package:fluttersdk_artisan/artisan.dart'; import 'package:path/path.dart' as path; +import '../helpers/creates_matching_test.dart'; import '../helpers/magic_stub_loader.dart'; +import 'make_test_command.dart'; /// The `make:repository` generator command. /// @@ -14,8 +16,10 @@ import '../helpers/magic_stub_loader.dart'; /// artisan make:repository Monitor # -> MonitorRepository /// artisan make:repository MonitorRepository # Suffix already present /// artisan make:repository Monitor --force # Overwrite existing file +/// artisan make:repository Monitor --test # Also scaffold the test /// ``` -class MakeRepositoryCommand extends ArtisanGeneratorCommand { +class MakeRepositoryCommand extends ArtisanGeneratorCommand + with CreatesMatchingTest { /// Optional test root override: injected in tests to avoid touching the /// real filesystem. final String? _testRoot; @@ -43,6 +47,16 @@ class MakeRepositoryCommand extends ArtisanGeneratorCommand { @override String getProjectRoot() => _testRoot ?? super.getProjectRoot(); + @override + Future handle(ArtisanContext ctx) async { + final int code = await super.handle(ctx); + if (code != 0 || !ctx.input.hasOption('test')) return code; + + // Chain the matching test onto a successful write. + final String name = ctx.input.argument(0)!; + return createMatchingTest(ctx, TestKind.repository, name); + } + /// Normalises [name] so the last path segment always carries the /// `Repository` suffix. Used by both [getPath] and [buildClass] to keep /// the class identifier, file name, and stub substitutions in sync. diff --git a/lib/src/cli/commands/make_request_command.dart b/lib/src/cli/commands/make_request_command.dart index 85b518c5..27669dfb 100644 --- a/lib/src/cli/commands/make_request_command.dart +++ b/lib/src/cli/commands/make_request_command.dart @@ -1,6 +1,8 @@ import 'package:fluttersdk_artisan/artisan.dart'; +import '../helpers/creates_matching_test.dart'; import '../helpers/magic_stub_loader.dart'; +import 'make_test_command.dart' show TestKind; import 'package:path/path.dart' as path; /// The `make:request` generator command. @@ -15,8 +17,9 @@ import 'package:path/path.dart' as path; /// artisan make:request StoreMonitorRequest # Suffix already present /// artisan make:request StoreMonitor --force # Overwrite existing file /// ``` -class MakeRequestCommand extends ArtisanGeneratorCommand { - /// Optional project root override — injected in tests to avoid touching the +class MakeRequestCommand extends ArtisanGeneratorCommand + with CreatesMatchingTest { + /// Optional project root override, injected in tests to avoid touching the /// real filesystem. final String? _testRoot; @@ -43,6 +46,16 @@ class MakeRequestCommand extends ArtisanGeneratorCommand { @override String getProjectRoot() => _testRoot ?? super.getProjectRoot(); + /// Scaffolds the request class, then chains `make:test --kind=request` + /// when `--test` was passed. + @override + Future handle(ArtisanContext ctx) async { + final code = await super.handle(ctx); + if (code != 0 || !ctx.input.hasOption('test')) return code; + + return createMatchingTest(ctx, TestKind.request, ctx.input.argument(0)!); + } + /// Normalises [name] so the last path segment always carries the `Request` /// suffix. Used by [buildClass] so the parent's internal class-name /// substitution writes the Request-suffixed identifier. diff --git a/lib/src/cli/commands/make_resource_command.dart b/lib/src/cli/commands/make_resource_command.dart new file mode 100644 index 00000000..13443b44 --- /dev/null +++ b/lib/src/cli/commands/make_resource_command.dart @@ -0,0 +1,294 @@ +import 'package:fluttersdk_artisan/artisan.dart'; +import 'package:path/path.dart' as path; + +import '../helpers/run_child.dart'; +import 'make_action_command.dart'; +import 'make_controller_command.dart'; +import 'make_factory_command.dart'; +import 'make_form_command.dart'; +import 'make_model_command.dart'; +import 'make_repository_command.dart'; +import 'make_request_command.dart'; +import 'make_test_command.dart'; +import 'make_view_command.dart'; + +/// One file of the vertical: the generator that owns it, the arguments that +/// make that generator write it, and where it lands. +/// +/// [keepExisting] marks the files a resource is built AROUND (the model and +/// its factory): present ones are skipped with a note instead of clashing. +typedef _Write = ({ + ArtisanCommand command, + List args, + String path, + bool keepExisting, +}); + +/// The `make:resource` generator command: magic's analogue of Laravel's +/// `make:model --all`. +/// +/// Composes the owning generators into one CRUD vertical for a model: model +/// and factory, repository, the create/update/delete actions, the Store and +/// Update requests, the resource form object, a `--resource --actions` +/// controller, and the list and form views, plus the tests for the actions, +/// the form and the controller. It writes no file itself; every file comes +/// from its own generator through [RunChild]. +/// +/// The run is all-or-nothing: every target path is checked before anything +/// is written. A present model or factory is kept; any other present file +/// fails the run (exit 1, nothing written) unless `--force` is passed. +/// +/// The route lines are printed for `RouteServiceProvider.boot()`, never +/// written into it. +/// +/// ## Usage +/// +/// ```bash +/// artisan make:resource Monitor +/// artisan make:resource Monitor --no-views # Data and logic layers only +/// artisan make:resource Monitor --no-model # The model already exists +/// artisan make:resource Monitor --force # Overwrite the clashes +/// ``` +class MakeResourceCommand extends ArtisanGeneratorCommand { + /// Optional test root override: injected in tests to avoid touching the + /// real filesystem. + final String? _testRoot; + + /// Creates a [MakeResourceCommand]. + /// + /// Pass [testRoot] to pin the project root to a temp directory during tests. + MakeResourceCommand({String? testRoot}) : _testRoot = testRoot; + + @override + CommandBoot get boot => CommandBoot.none; + + @override + String get name => 'make:resource'; + + @override + String get description => + 'Create a model with its repository, actions, requests, form, ' + 'controller, views and tests'; + + @override + String getDefaultNamespace() => 'lib/app'; + + /// This command composes other generators; [getStub] is unused (mirrors + /// `make:test` and `make:component`). + @override + String getStub() => ''; + + @override + String getProjectRoot() => _testRoot ?? super.getProjectRoot(); + + @override + void configure(ArgParser parser) { + super.configure(parser); + parser.addFlag( + 'views', + help: 'Generate the list and form views (--no-views to skip).', + defaultsTo: true, + ); + parser.addFlag( + 'model', + help: 'Generate the model and its factory (--no-model to skip).', + defaultsTo: true, + ); + } + + @override + Future handle(ArtisanContext ctx) async { + final String? model = ctx.input.argument(0); + if (model == null || model.isEmpty) { + ctx.output.error('Not enough arguments (missing: "name").'); + return 1; + } + + // 1. The chained make:test reads the package name from pubspec.yaml; + // without it the run would stop halfway through the vertical. + final String pubspecPath = path.join(getProjectRoot(), 'pubspec.yaml'); + if (!FileHelper.fileExists(pubspecPath)) { + ctx.output.error('pubspec.yaml not found at $pubspecPath'); + return 1; + } + + final bool force = ctx.input.hasOption('force'); + final bool withViews = ctx.input.option('views') as bool; + final List<_Write> plan = _plan( + model, + withModel: ctx.input.option('model') as bool, + withViews: withViews, + ); + + // 2. Preflight every target before the first write, so a clash leaves + // the project exactly as it was. + final List<_Write> writes = <_Write>[]; + final List clashes = []; + for (final _Write write in plan) { + if (!FileHelper.fileExists(write.path)) { + writes.add(write); + } else if (write.keepExisting) { + ctx.output.info('Skipped: ${write.path} (already exists)'); + } else if (force) { + writes.add(write); + } else { + clashes.add(write.path); + } + } + if (clashes.isNotEmpty) { + for (final String clash in clashes) { + ctx.output.error('File already exists at $clash'); + } + ctx.output.error('Nothing was written. Pass --force to overwrite.'); + return 1; + } + + // 3. Hand each file to its owning generator; stop at the first failure. + for (final _Write write in writes) { + final int code = await RunChild.run(write.command, [ + ...write.args, + if (force) '--force', + ], ctx); + if (code != 0) return code; + } + + // 4. Routes belong to the app's RouteServiceProvider: print, never edit. + if (withViews) _printRoutes(ctx, model); + + return 0; + } + + /// Builds the ordered list of files the vertical for [model] consists of, + /// each resolved to its path by its owning generator's own `getPath`. + List<_Write> _plan( + String model, { + required bool withModel, + required bool withViews, + }) { + final String snake = StringHelper.toSnakeCase(model); + final String plural = StringHelper.toPlural(snake); + final String pluralClass = StringHelper.toPascalCase(plural); + + final List<_Write> plan = <_Write>[]; + + void add( + ArtisanGeneratorCommand command, + List args, + String pathName, { + TestKind? test, + bool keepExisting = false, + }) { + final String filePath = command.getPath(pathName); + plan.add(( + command: command, + args: args, + path: filePath, + keepExisting: keepExisting, + )); + if (test == null) return; + + plan.add(( + command: MakeTestCommand(testRoot: _testRoot), + args: [args.first, '--kind=${test.value}'], + path: _testPathFor(filePath), + keepExisting: false, + )); + } + + // 1. The data layer: model, factory, repository. + if (withModel) { + add( + MakeModelCommand(testRoot: _testRoot), + [model], + model, + keepExisting: true, + ); + add( + MakeFactoryCommand(testRoot: _testRoot), + [model], + model, + keepExisting: true, + ); + } + add(MakeRepositoryCommand(testRoot: _testRoot), [model], model); + + // 2. The write path: one action per kind, then the requests they send. + for (final String kind in const ['create', 'update', 'delete']) { + final String action = '$plural/${StringHelper.toPascalCase(kind)}$model'; + add( + MakeActionCommand(testRoot: _testRoot), + [action, '--kind=$kind', '--model=$model'], + action, + test: TestKind.action, + ); + } + for (final String verb in const ['Store', 'Update']) { + add(MakeRequestCommand(testRoot: _testRoot), [ + '$verb$model', + ], '$verb$model'); + } + + // 3. The form object and the controller that read and write through it. + add( + MakeFormCommand(testRoot: _testRoot), + [model, '--resource=$model'], + model, + test: TestKind.form, + ); + add( + MakeControllerCommand(testRoot: _testRoot), + [model, '--resource', '--actions', '--model=$model'], + '${model}Controller', + test: TestKind.controller, + ); + + // 4. The screens. + if (withViews) { + add(MakeViewCommand(testRoot: _testRoot), [ + '$plural/${pluralClass}List', + '--controller=$model', + '--list', + ], '$plural/${pluralClass}ListView'); + add(MakeViewCommand(testRoot: _testRoot), [ + '$plural/${model}Form', + '--controller=$model', + '--form=${model}FormObject', + ], '$plural/${model}FormView'); + } + + return plan; + } + + /// The test `make:test` writes for the class at [classPath]: the same path + /// with `lib/` swapped for `test/` and a `_test` suffix. + String _testPathFor(String classPath) { + final String root = getProjectRoot(); + final String relative = path.relative(classPath, from: root); + final String mirrored = path.joinAll([ + 'test', + ...path.split(relative).skip(1), + ]); + return path.join(root, '${path.withoutExtension(mirrored)}_test.dart'); + } + + /// Prints the index and create routes for [model]'s views, ready to paste + /// into `RouteServiceProvider.boot()`. + void _printRoutes(ArtisanContext ctx, String model) { + final String plural = StringHelper.toPlural( + StringHelper.toSnakeCase(model), + ); + final String pluralClass = StringHelper.toPascalCase(plural); + + ctx.output + ..info('') + ..info('Add these routes to RouteServiceProvider.boot():') + ..info( + " MagicRoute.page('/$plural', () => const ${pluralClass}ListView())" + ".name('$plural.index');", + ) + ..info( + " MagicRoute.page('/$plural/create', () => const ${model}FormView())" + ".name('$plural.create').stacked();", + ); + } +} diff --git a/lib/src/cli/commands/make_test_command.dart b/lib/src/cli/commands/make_test_command.dart new file mode 100644 index 00000000..6353ac77 --- /dev/null +++ b/lib/src/cli/commands/make_test_command.dart @@ -0,0 +1,264 @@ +import 'package:fluttersdk_artisan/artisan.dart'; +import 'package:path/path.dart' as path; + +import '../helpers/magic_stub_loader.dart'; + +/// The generator kinds `make:test --kind=` understands, one per +/// `make:*` command that carries a matching-test convention (see +/// `CreatesMatchingTest`). +enum TestKind { + controller('controller'), + action('action'), + form('form'), + repository('repository'), + request('request'), + view('view'), + unit('unit'); + + const TestKind(this.value); + + /// The `--kind=` string this member parses from. + final String value; + + /// Resolves [raw] to a [TestKind], or `null` when it matches none. + static TestKind? fromValue(String? raw) { + for (final kind in TestKind.values) { + if (kind.value == raw) return kind; + } + return null; + } +} + +/// Where a [TestKind]'s source class lives and how its test mirrors it. +class _KindSpec { + const _KindSpec({ + required this.libNamespace, + required this.testNamespace, + required this.classSuffix, + required this.stubName, + }); + + /// Where the generator under test writes its class, relative to the + /// project root (e.g. `lib/app/controllers`). Empty for `unit`, which has + /// no source-file counterpart. + final String libNamespace; + + /// Where the matching test lives, relative to the project root (e.g. + /// `test/app/controllers`). + final String testNamespace; + + /// Suffix guaranteed on the class/file name (e.g. `Controller`); `null` + /// when the kind carries no suffix (action, unit). + final String? classSuffix; + + /// Stub name (without `.stub`) rendering this kind's test skeleton. + final String stubName; +} + +/// The `make:test` generator command. +/// +/// Scaffolds a test skeleton mirroring where a `make:*` generator's own +/// output lives (Laravel's `make:test`, ported to magic's `lib/` -> `test/` +/// layout): `lib/app/controllers/monitor_controller.dart` gets +/// `test/app/controllers/monitor_controller_test.dart`, and so on for +/// `--kind=action|form|repository|request|view|unit`. +/// +/// ## Usage +/// +/// ```bash +/// artisan make:test Monitor --kind=controller +/// artisan make:test Monitors/PauseMonitor --kind=action # Nested path +/// artisan make:test Monitor --kind=repository --force # Overwrite +/// ``` +/// +/// The generated test imports the target class as `package:/...`, +/// where `` is read from the target project's own `pubspec.yaml` +/// (relative `../lib/` imports would trip `avoid_relative_lib_imports`). +class MakeTestCommand extends ArtisanGeneratorCommand { + /// Optional test root override; enables isolation in unit tests. + final String? _testRoot; + + /// Creates a [MakeTestCommand]. + /// + /// [testRoot] overrides the project root resolution, used in tests only. + MakeTestCommand({String? testRoot}) : _testRoot = testRoot; + + static const Map _specs = { + TestKind.controller: _KindSpec( + libNamespace: 'lib/app/controllers', + testNamespace: 'test/app/controllers', + classSuffix: 'Controller', + stubName: 'test.controller', + ), + TestKind.action: _KindSpec( + libNamespace: 'lib/app/actions', + testNamespace: 'test/app/actions', + classSuffix: null, + stubName: 'test.action', + ), + TestKind.form: _KindSpec( + libNamespace: 'lib/app/forms', + testNamespace: 'test/app/forms', + classSuffix: 'FormObject', + stubName: 'test.form', + ), + TestKind.repository: _KindSpec( + libNamespace: 'lib/app/repositories', + testNamespace: 'test/app/repositories', + classSuffix: 'Repository', + stubName: 'test.repository', + ), + TestKind.request: _KindSpec( + libNamespace: 'lib/app/validation/requests', + testNamespace: 'test/app/validation/requests', + classSuffix: 'Request', + stubName: 'test.request', + ), + TestKind.view: _KindSpec( + libNamespace: 'lib/resources/views', + testNamespace: 'test/resources/views', + classSuffix: 'View', + stubName: 'test.view', + ), + TestKind.unit: _KindSpec( + libNamespace: '', + testNamespace: 'test/unit', + classSuffix: null, + stubName: 'test.unit', + ), + }; + + @override + CommandBoot get boot => CommandBoot.none; + + @override + String get name => 'make:test'; + + @override + String get description => + "Create a test skeleton mirroring a generator's output"; + + @override + String getDefaultNamespace() => 'test'; + + @override + String getProjectRoot() => _testRoot ?? super.getProjectRoot(); + + /// This command picks its stub per `--kind` inside [handle]; [getStub] is + /// unused (mirrors `make:component`'s multi-file generators). + @override + String getStub() => ''; + + @override + void configure(ArgParser parser) { + super.configure(parser); + parser.addOption( + 'kind', + help: + 'The generator kind whose output this test mirrors: ' + '${TestKind.values.map((k) => k.value).join('|')}.', + ); + } + + @override + Future handle(ArtisanContext ctx) async { + // 1. Validate the required positional name. + final rawName = ctx.input.argument(0); + if (rawName == null || rawName.isEmpty) { + ctx.output.error('Not enough arguments (missing: "name").'); + return 1; + } + + // 2. Validate --kind against the known set. + final kindOption = ctx.input.option('kind') as String?; + final kind = TestKind.fromValue(kindOption); + if (kind == null) { + final known = TestKind.values.map((k) => k.value).join(', '); + ctx.output.error( + 'Unknown --kind "${kindOption ?? ''}". Expected one of: $known.', + ); + return 1; + } + + // 3. Resolve the target project's own package name from its + // pubspec.yaml; the generated test imports app code as + // `package:/...`. + final projectRoot = getProjectRoot(); + final pubspecPath = path.join(projectRoot, 'pubspec.yaml'); + if (!FileHelper.fileExists(pubspecPath)) { + ctx.output.error('pubspec.yaml not found at $pubspecPath'); + return 1; + } + final Object? packageName = FileHelper.readYamlFile(pubspecPath)['name']; + if (packageName is! String || packageName.isEmpty) { + ctx.output.error('pubspec.yaml at $pubspecPath declares no package name'); + return 1; + } + + // 4. Resolve the output path and class name for this kind. + final spec = _specs[kind]!; + final parsed = StringHelper.parseName(rawName); + final className = _withSuffix(parsed.className, spec.classSuffix); + final fileStem = StringHelper.toSnakeCase(className); + final filePath = parsed.directory.isEmpty + ? path.join(projectRoot, spec.testNamespace, '${fileStem}_test.dart') + : path.join( + projectRoot, + spec.testNamespace, + parsed.directory, + '${fileStem}_test.dart', + ); + + // 5. Abort if the test exists and --force was not provided. + if (FileHelper.fileExists(filePath) && !ctx.input.hasOption('force')) { + ctx.output.error('File already exists at $filePath'); + return 1; + } + + // 6. Build and write the test skeleton from the kind's stub. + final content = _render( + spec, + className, + packageName, + parsed.directory, + fileStem, + ); + FileHelper.writeFile(filePath, content); + ctx.output.success('Created: $filePath'); + return 0; + } + + /// Returns [className] with [suffix] appended when absent; unchanged when + /// [suffix] is `null` (action, unit carry no suffix). + String _withSuffix(String className, String? suffix) { + if (suffix == null) return className; + return className.endsWith(suffix) ? className : '$className$suffix'; + } + + /// Loads [spec]'s stub and substitutes `{{ className }}`, `{{ packageName + /// }}`, and `{{ importPath }}`. + String _render( + _KindSpec spec, + String className, + String packageName, + String directory, + String fileStem, + ) { + final stub = MagicStubLoader.load(spec.stubName); + return stub + .replaceAll('{{ className }}', className) + .replaceAll('{{ packageName }}', packageName) + .replaceAll('{{ importPath }}', _importPath(spec, directory, fileStem)); + } + + /// The `package:/`-relative import path to the source class, or an + /// empty string for `unit` (no source-file counterpart; its stub only + /// substitutes `{{ packageName }}` inside a TODO comment). + String _importPath(_KindSpec spec, String directory, String fileStem) { + if (spec.libNamespace.isEmpty) return ''; + final sourceNamespace = spec.libNamespace.substring('lib/'.length); + return directory.isEmpty + ? '$sourceNamespace/$fileStem.dart' + : '$sourceNamespace/$directory/$fileStem.dart'; + } +} diff --git a/lib/src/cli/commands/make_view_command.dart b/lib/src/cli/commands/make_view_command.dart index 6461c473..cd38cec4 100644 --- a/lib/src/cli/commands/make_view_command.dart +++ b/lib/src/cli/commands/make_view_command.dart @@ -1,28 +1,52 @@ import 'package:fluttersdk_artisan/artisan.dart'; +import '../helpers/creates_matching_test.dart'; import '../helpers/magic_stub_loader.dart'; +import 'make_test_command.dart' show TestKind; + +/// A resolved suffixed identifier (a controller or a form object): the class +/// name with its suffix guaranteed, and the directory it lives under +/// (relative to its own default namespace, empty when flat). +typedef _SuffixedName = ({String className, String directory}); /// The `make:view` generator command. /// -/// Scaffolds a new MagicView class using the view stub templates. +/// Scaffolds a new view class using the view stub templates: a plain +/// `StatelessWidget` for a view with no backing controller, or a +/// `MagicStatefulView` wired to one. /// /// ## Usage /// /// ```bash -/// artisan make:view Login # → lib/resources/views/login_view.dart -/// artisan make:view Auth/Register # → lib/resources/views/auth/register_view.dart -/// artisan make:view Dashboard --stateful # → Stateful view with lifecycle hooks +/// artisan make:view Login # → StatelessWidget +/// artisan make:view Auth/Register # Nested path +/// artisan make:view Monitor --controller=Monitor # MagicStatefulView +/// artisan make:view Login --stateful # Controller derived: LoginController +/// artisan make:view Monitors/List --controller=Monitor --list --form=MonitorFormObject /// ``` /// -/// The `View` suffix is appended automatically when omitted. -class MakeViewCommand extends ArtisanGeneratorCommand { - /// Optional test root override — enables isolation in unit tests. +/// The `View` suffix is appended automatically when omitted. `--list` expects +/// a controller made with `make:controller --resource`, which exposes the +/// `ensureFresh()` method [RefetchesOnMount] calls. +class MakeViewCommand extends ArtisanGeneratorCommand with CreatesMatchingTest { + /// Optional test root override; enables isolation in unit tests. final String? _testRoot; /// Captures the parsed `--stateful` flag at [handle] time so [getStub] can /// honour it without re-reading the [ArtisanContext.input]. bool _statefulFlag = false; + /// Captures the parsed `--list` flag at [handle] time. + bool _listFlag = false; + + /// Captures the parsed `--controller` value at [handle] time, or `null` + /// when the controller should be derived from the view's own name. + String? _controllerOption; + + /// Captures the parsed `--form` value at [handle] time, or `null` when no + /// form object is wired. + String? _formOption; + /// Creates a [MakeViewCommand]. /// /// [testRoot] overrides the project root resolution, used in tests only. @@ -45,7 +69,7 @@ class MakeViewCommand extends ArtisanGeneratorCommand { @override void configure(ArgParser parser) { - // 1. Register --force (and base args) from parent first. + // 1. Register --force, --test (and base args) from parent first. super.configure(parser); // 2. Add view-specific flags. @@ -54,11 +78,38 @@ class MakeViewCommand extends ArtisanGeneratorCommand { help: 'Generate a stateful view with lifecycle hooks', negatable: false, ); + parser.addOption( + 'controller', + help: + 'Bind the view to this controller (suffix optional); implies ' + '--stateful.', + ); + parser.addFlag( + 'list', + help: + 'Add RefetchesOnMount so the view reloads on every mount. Expects ' + 'a controller made with `make:controller --resource`, which ' + 'exposes `ensureFresh()`.', + negatable: false, + ); + parser.addOption( + 'form', + help: + 'Add a State-owned form object field (suffix optional), disposed ' + 'in onClose; implies --stateful.', + ); } + /// Whether any flag captured in [handle] calls for a stateful view. + bool get _isStateful => + _statefulFlag || + _listFlag || + (_controllerOption?.isNotEmpty ?? false) || + (_formOption?.isNotEmpty ?? false); + @override String getStub() => - MagicStubLoader.load(_statefulFlag ? 'view.stateful' : 'view'); + MagicStubLoader.load(_isStateful ? 'view.stateful' : 'view'); /// Provides extra placeholder replacements for the view stub. /// @@ -67,7 +118,49 @@ class MakeViewCommand extends ArtisanGeneratorCommand { @override Map getReplacements(String name) { final parsed = StringHelper.parseName(name); - return {'{{ snakeName }}': StringHelper.toSnakeCase(parsed.className)}; + final replacements = { + '{{ snakeName }}': StringHelper.toSnakeCase(parsed.className), + }; + + if (!_isStateful) return replacements; + + // The number of `../` steps from the generated file back up to `lib/`: + // 2 for `resources/views` plus one per nested view directory segment + // (e.g. `lib/resources/views/monitors/monitors_list_view.dart` needs 3). + final viewDirDepth = parsed.directory.isEmpty + ? 0 + : parsed.directory.split('/').length; + final upCount = 2 + viewDirDepth; + + final controller = _resolveController(parsed.className); + replacements['{{ controllerClassName }}'] = controller.className; + replacements['{{ controllerImportPath }}'] = _relativeImportPath( + upCount, + 'app/controllers', + controller, + ); + replacements['{{ mixinClause }}'] = _listFlag + ? '\n with RefetchesOnMount<${controller.className}, ' + '${parsed.className}View>' + : ''; + + String? formClassName; + if (_formOption != null && _formOption!.isNotEmpty) { + final form = _resolveSuffixed(_formOption!, 'FormObject'); + formClassName = form.className; + replacements['{{ formImportLine }}'] = + "\nimport '${_relativeImportPath(upCount, 'app/forms', form)}';"; + } else { + replacements['{{ formImportLine }}'] = ''; + } + + replacements['{{ stateBody }}'] = _buildStateBody( + controllerClassName: controller.className, + formClassName: formClassName, + isList: _listFlag, + ); + + return replacements; } @override @@ -78,8 +171,12 @@ class MakeViewCommand extends ArtisanGeneratorCommand { return 1; } - // 1. Capture --stateful so [getStub] selects the right template. + // 1. Capture flags so getStub/getReplacements can honour them without + // re-reading the context. _statefulFlag = ctx.input.hasOption('stateful'); + _listFlag = ctx.input.hasOption('list'); + _controllerOption = ctx.input.option('controller') as String?; + _formOption = ctx.input.option('form') as String?; // 2. Derive base name (no View suffix) and full name (with suffix). final baseName = _stripSuffix(rawName, 'View'); @@ -96,14 +193,108 @@ class MakeViewCommand extends ArtisanGeneratorCommand { } // 5. Build stub content using the BASE name so {{ className }} resolves - // correctly — the stub appends "View" to the placeholder itself. + // correctly; the stub appends "View" to the placeholder itself. final content = buildClass(baseName); FileHelper.writeFile(filePath, content); ctx.output.success('Created: $filePath'); + + // 6. Chain make:test --kind=view when --test was passed. + if (ctx.input.hasOption('test')) { + return createMatchingTest(ctx, TestKind.view, rawName); + } + return 0; } + /// Resolves the controller this view binds to: the `--controller` value + /// when given (suffix guaranteed), or one derived from the view's own + /// [viewClassName] (e.g. `Login` → `LoginController`). + _SuffixedName _resolveController(String viewClassName) { + final option = _controllerOption; + if (option != null && option.isNotEmpty) { + return _resolveSuffixed(option, 'Controller'); + } + return (className: '${viewClassName}Controller', directory: ''); + } + + /// Resolves [raw] to a suffixed class name and its directory, guaranteeing + /// [suffix] is present exactly once. + _SuffixedName _resolveSuffixed(String raw, String suffix) { + final parsed = StringHelper.parseName(raw); + final className = parsed.className.endsWith(suffix) + ? parsed.className + : '${parsed.className}$suffix'; + return (className: className, directory: parsed.directory); + } + + /// The relative import path from the generated view file back up + /// [upCount] directories, then down into `lib/$appSubpath/$name`. + String _relativeImportPath( + int upCount, + String appSubpath, + _SuffixedName name, + ) { + final ups = '../' * upCount; + final dirSegment = name.directory.isEmpty ? '' : '${name.directory}/'; + final fileName = StringHelper.toSnakeCase(name.className); + return '$ups$appSubpath/$dirSegment$fileName.dart'; + } + + /// Renders the `_XViewState` body: the optional form field, the + /// controller-registering `initState`, the optional `onClose` (form + /// disposal) and the optional `refetch` override (`--list`). + String _buildStateBody({ + required String controllerClassName, + required String? formClassName, + required bool isList, + }) { + final buffer = StringBuffer(); + + if (formClassName != null) { + buffer + ..writeln( + " /// The form object backing this screen's inputs, disposed in " + '[onClose].', + ) + ..writeln(' late final form = $formClassName();') + ..writeln(); + } + + buffer + ..writeln(' @override') + ..writeln(' void initState() {') + ..writeln( + ' // Register the controller before the base state resolves it ' + 'via', + ) + ..writeln( + ' // Magic.find() (which throws when unregistered). ' + 'Idempotent.', + ) + ..writeln(' Magic.findOrPut($controllerClassName.new);') + ..writeln(' super.initState();') + ..writeln(' }'); + + if (formClassName != null) { + buffer + ..writeln() + ..writeln(' @override') + ..writeln(' void onClose() {') + ..writeln(' form.dispose();') + ..writeln(' }'); + } + + if (isList) { + buffer + ..writeln() + ..writeln(' @override') + ..writeln(' Future refetch() => controller.ensureFresh();'); + } + + return buffer.toString(); + } + /// Returns [name] with [suffix] appended to the last path segment if absent. String _withSuffix(String name, String suffix) { final parts = name.split('/'); diff --git a/lib/src/cli/helpers/creates_matching_test.dart b/lib/src/cli/helpers/creates_matching_test.dart new file mode 100644 index 00000000..70bc71f6 --- /dev/null +++ b/lib/src/cli/helpers/creates_matching_test.dart @@ -0,0 +1,45 @@ +import 'package:fluttersdk_artisan/artisan.dart'; + +import '../commands/make_test_command.dart'; +import 'run_child.dart'; + +/// Adds a shared `--test` flag to a `make:*` generator, so `--test` chains +/// `make:test` to scaffold the matching test right after the class itself. +/// +/// A host command applies the mixin (`class MakeXCommand extends +/// ArtisanGeneratorCommand with CreatesMatchingTest`), which registers +/// `--test` automatically via [configure]; the host's `handle` calls +/// [createMatchingTest] when `ctx.input.hasOption('test')` is true. +mixin CreatesMatchingTest on ArtisanGeneratorCommand { + @override + void configure(ArgParser parser) { + super.configure(parser); + parser.addFlag( + 'test', + help: 'Also scaffold the matching test for the generated class.', + negatable: false, + ); + } + + /// Chains `make:test --kind=` through [RunChild], reusing + /// [ctx]'s output stream so both files report in the same feedback block. + /// + /// [name] is the same name the host generator received (nested paths like + /// `Monitors/CreateMonitor` are supported), so the generated test mirrors + /// the generated class's location. The child's project root is pinned to + /// this command's own [getProjectRoot], so the chained write lands in the + /// same (possibly test-isolated) tree the host wrote to. + Future createMatchingTest( + ArtisanContext ctx, + TestKind kind, + String name, + ) { + return RunChild.run(MakeTestCommand(testRoot: getProjectRoot()), [ + name, + '--kind=${kind.value}', + // A host run with --force regenerates its test too, rather than + // stopping on the test the previous run wrote. + if (ctx.input.hasOption('force')) '--force', + ], ctx); + } +} diff --git a/lib/src/cli/helpers/run_child.dart b/lib/src/cli/helpers/run_child.dart new file mode 100644 index 00000000..5b9616d0 --- /dev/null +++ b/lib/src/cli/helpers/run_child.dart @@ -0,0 +1,29 @@ +import 'package:fluttersdk_artisan/artisan.dart'; + +/// Runs a sibling artisan command programmatically, in-process. +/// +/// Shared by every `make:*` command that chains another generator: +/// `make:model --all` (migration/factory/seeder/policy/controller), +/// `make:component` (`previews:refresh`), and [CreatesMatchingTest]'s +/// `--test` flag (`make:test`). +class RunChild { + const RunChild._(); + + /// Parses [args] against [command]'s own [ArgParser], wraps the result in + /// an [ArgvInput], and hands the child a [ArtisanContext.bare] that reuses + /// [parentCtx]'s [ArtisanOutput] so the operator sees one uninterrupted + /// feedback stream. + /// + /// The child runs in a bare context: chained `make:*` commands never need + /// a VM Service connection. + static Future run( + ArtisanCommand command, + List args, + ArtisanContext parentCtx, + ) async { + final parser = ArgParser(); + command.configure(parser); + final input = ArgvInput.parse(parser, args); + return command.handle(ArtisanContext.bare(input, parentCtx.output)); + } +} diff --git a/lib/src/cli/magic_artisan_provider.dart b/lib/src/cli/magic_artisan_provider.dart index e100fbb1..d7394c1f 100644 --- a/lib/src/cli/magic_artisan_provider.dart +++ b/lib/src/cli/magic_artisan_provider.dart @@ -20,7 +20,9 @@ import 'commands/make_policy_command.dart'; import 'commands/make_provider_command.dart'; import 'commands/make_repository_command.dart'; import 'commands/make_request_command.dart'; +import 'commands/make_resource_command.dart'; import 'commands/make_seeder_command.dart'; +import 'commands/make_test_command.dart'; import 'commands/make_view_command.dart'; import 'commands/previews_refresh_command.dart'; @@ -37,7 +39,7 @@ import 'commands/previews_refresh_command.dart'; /// }; /// ``` /// -/// Ships the magic code-gen surface: 18 make:* generators + `previews:refresh` +/// Ships the magic code-gen surface: 20 make:* generators + `previews:refresh` /// + `design:sync` + `design:lint` + `magic:install` + `key:generate`. class MagicArtisanProvider extends ArtisanServiceProvider { @override @@ -63,6 +65,8 @@ class MagicArtisanProvider extends ArtisanServiceProvider { MakeActionCommand(), MakeFormCommand(), MakeRepositoryCommand(), + MakeResourceCommand(), + MakeTestCommand(), PreviewsRefreshCommand(), DesignSyncCommand(), DesignLintCommand(), diff --git a/skills/magic-framework/SKILL.md b/skills/magic-framework/SKILL.md index 79832527..a82bbfad 100644 --- a/skills/magic-framework/SKILL.md +++ b/skills/magic-framework/SKILL.md @@ -2,10 +2,10 @@ name: magic-framework description: "Write correct, idiomatic code in a Flutter app that depends on the `magic` framework (Laravel-inspired: IoC container, 18 facades, Eloquent-style ORM, service providers, reactive controllers, GoRouter routing, validation, auth, broadcasting, MagicAction writes, Repository row caches, SessionScope tenant resets). Use whenever code imports `package:magic/magic.dart` or `package:magic/testing.dart`, or the work touches Magic.init, MagicApp, a facade (Auth/Http/Cache/DB/Echo/Event/Gate/Config/Lang/Launch/Log/Pick/MagicRoute/Schema/Session/Storage/Vault/Crypt), a Model, MagicController, a MagicView, MagicFormData, FormRequest, MagicAction, MagicFormObject, Repository, SessionScope, BroadcastListeners, a ServiceProvider, a migration, or the artisan make:* CLI. UI styling is Wind (separate wind-ui skill). Do NOT use for plain Flutter or Wind-only work with no magic import." when_to_use: "Use proactively when editing or scaffolding a magic app: Magic.init / a facade / a Model / a MagicController or MagicView / a form (MagicFormData, FormRequest, Validator, MagicFormObject) / a write (MagicAction, RunsActions) / a resource cache (Repository, RepositoryQuery) / a tenant boundary (SessionScope, SessionScoped) / a shared realtime channel (BroadcastListeners, ListensToBroadcasts) / a ServiceProvider / a route or MagicMiddleware / a migration / MagicStateMixin + RxStatus + fetchList / Session flash + old() + trans() / testing with MagicTest + Http.fake/Auth.fake / the artisan make:* CLI / the magic_deeplink, magic_notifications, magic_social_auth, magic_starter, magic_payments, magic_devtools, or magic_sentry plugins. Trigger even when the user does not say the word 'magic'. Do NOT trigger for plain Flutter or Wind-only UI with no package:magic import." -version: 0.1.52 +version: 0.1.53 --- - + # Magic Framework @@ -405,12 +405,13 @@ dart run magic:artisan make:view Login --stateful # stateful view dart run magic:artisan make:migration create_users # migration dart run magic:artisan make:request StoreUser # form request dart run magic:artisan make:policy User # authorization policy +dart run magic:artisan make:resource Monitor # full CRUD vertical: model, repository, actions, requests, form, controller, views, tests dart run magic:artisan key:generate # APP_KEY ``` -Other generators: `make:seeder`, `make:factory`, `make:middleware`, `make:provider`, `make:event`, `make:listener`, `make:enum`, `make:lang`. Generators accept `--force` and nested paths (`Admin/Dashboard`). +Other generators: `make:seeder`, `make:factory`, `make:middleware`, `make:provider`, `make:event`, `make:listener`, `make:enum` (`--wire` for a backend-mirroring enum), `make:lang` (`--from=` to copy an existing catalogue's key tree), `make:repository`, `make:action` (`--kind=create|update|delete --model=`), `make:form` (`--resource=` for the full create/edit contract), `make:test` (`--kind=controller|action|form|repository|request|view|unit`, mirrors a generator's own output under `test/`). `make:controller`, `make:view`, `make:action`, `make:form`, `make:repository` and `make:request` also accept `--test` to chain `make:test` onto a successful write. Generators accept `--force` and nested paths (`Admin/Dashboard`). -Design-first workflow: `make:component Avatar [--variants=intent,size] [--slots]` scaffolds a 4-file atomic component folder (`avatar.dart` / `avatar.recipe.dart` / `avatar.preview.dart` / `index.dart`) under `lib/ui/components/` and chains `previews:refresh`. `previews:refresh [--path=lib]` regenerates `_previews.g.dart` from all `*.preview.dart` files (returns a `List` function, never a const list). `design:sync [--input=DESIGN.md] [--output=lib/config/wind_theme.g.dart]` emits `designAliases` and `designColors` from a DESIGN.md front-matter. `design:lint [--input=DESIGN.md]` validates against 7 rules (exits nonzero on error-severity only). +Design-first workflow: `make:component Avatar [--variants=intent,size] [--slots]` scaffolds an atomic component folder (`avatar.dart` / `avatar.recipe.dart` / `index.dart`) under `lib/ui/components/` plus its widget test; `avatar.preview.dart` and the chained `previews:refresh` come only when the project already keeps a preview catalogue (`--preview` / `--no-preview` override). `previews:refresh [--path=lib]` regenerates `_previews.g.dart` from all `*.preview.dart` files (returns a `List` function, never a const list). `design:sync [--input=DESIGN.md] [--output=lib/config/wind_theme.g.dart]` emits `designAliases` and `designColors` from a DESIGN.md front-matter. `design:lint [--input=DESIGN.md]` validates against 7 rules (exits nonzero on error-severity only). Full reference: `${CLAUDE_SKILL_DIR}/references/cli-commands.md`. diff --git a/skills/magic-framework/references/cli-commands.md b/skills/magic-framework/references/cli-commands.md index 02c12b53..f6b18829 100644 --- a/skills/magic-framework/references/cli-commands.md +++ b/skills/magic-framework/references/cli-commands.md @@ -36,6 +36,11 @@ All commands are invoked via `dart run magic:artisan ` from the Flutter | **Generator** | `dart run magic:artisan make:request Name` | Form request (validation rules class) | | **Generator** | `dart run magic:artisan make:lang code` | JSON language file | | **Generator** | `dart run magic:artisan make:component Name` | Atomic component folder (recipe + component + preview + index) | +| **Generator** | `dart run magic:artisan make:repository Name` | `Repository` cache class for one model's rows | +| **Generator** | `dart run magic:artisan make:action Name` | `MagicAction` write unit, plain or `--kind=create\|update\|delete` | +| **Generator** | `dart run magic:artisan make:form Name` | `MagicFormObject` class, plain or `--resource=` | +| **Generator** | `dart run magic:artisan make:test Name --kind=` | Test skeleton mirroring another generator's output | +| **Generator** | `dart run magic:artisan make:resource Name` | Full CRUD vertical: composes model, repository, actions, requests, form, controller, views and tests | | **Codegen** | `dart run magic:artisan previews:refresh` | Regenerate `_previews.g.dart` from `*.preview.dart` files | | **Design** | `dart run magic:artisan design:sync` | Generate the wind theme (aliases + brand seed) from `DESIGN.md` | | **Design** | `dart run magic:artisan design:lint` | Validate `DESIGN.md` against the design rules | @@ -132,6 +137,7 @@ All generators share these conventions: - **Auto-suffix**: `dart run magic:artisan make:controller User` → `UserController`. The suffix is appended if not already present; existing suffixes are detected and not doubled. - **Nested paths**: `dart run magic:artisan make:controller Admin/Dashboard` → `lib/app/controllers/admin/dashboard_controller.dart`. Directory segments are converted to snake_case. - **`--force` flag**: All generators accept `--force` to overwrite existing files. +- **`--test` flag**: `make:controller`, `make:view`, `make:action`, `make:form`, `make:repository` and `make:request` also accept `--test`, which chains `make:test --kind=` onto a successful write so the matching test skeleton lands alongside the class in the same run. `--force` on the host forwards to the chained `make:test` too. - **Import statement**: Generated files automatically import `package:magic/magic.dart`. ### `dart run magic:artisan make:model` @@ -156,6 +162,8 @@ dart run magic:artisan make:model Monitor -a # all companion files | `--policy` | `-p` | Policy in `lib/app/policies/` | | `--all` | `-a` | All of the above (controller generated as resource) | +An existing model file without `--force` stops the run before any companion (migration, factory, seeder, policy, controller) is generated against it, the same as every other generator. + **Output**: `lib/app/models/monitor.dart` **Generated stub:** @@ -177,23 +185,37 @@ class Monitor extends Model with HasTimestamps, InteractsWithPersistence { InteractsWithPersistence.findById(id, Monitor.new); static Future> all() => InteractsWithPersistence.allModels(Monitor.new); + + static Monitor fromMap(Map map) => + Monitor()..setRawAttributes(map, sync: true)..exists = map.containsKey('id'); } ``` +`fromMap` hydrates the model directly from raw API data via `setRawAttributes`, bypassing the `fillable` mass-assignment guard (unlike `fill`); `exists` is set from whether the map carries an `id` key. + ### `dart run magic:artisan make:controller` -Creates a controller class. +Creates a state-owning `MagicController` implementing `SessionScoped`: a plain controller by default, or (with `--resource`) a read controller wrapping a `RepositoryQuery` over the target model's repository. ```bash dart run magic:artisan make:controller Monitor -dart run magic:artisan make:controller Monitor --resource # CRUD resource controller -dart run magic:artisan make:controller Admin/Dashboard # Nested path +dart run magic:artisan make:controller Monitor --resource # reads MonitorRepository +dart run magic:artisan make:controller Monitor --resource --model=Ping # reads PingRepository +dart run magic:artisan make:controller Monitor --broadcasts --timers --actions --validates +dart run magic:artisan make:controller Admin/Dashboard --test # + matching test ``` | Flag | Short | Effect | |:-----|:------|:-------| -| `--resource` | `-r` | Generate with CRUD methods: `index`, `create`, `show`, `edit`, `store`, `update`, `destroy` | -| `--model` | `-m` | Specify the model name for the resource controller | +| `--resource` | `-r` | Generate a read controller owning a `RepositoryQuery` over the model | +| `--model` | `-m` | The model a `--resource` controller reads (default: the controller name) | +| `--actions` | | Mix in `RunsActions` | +| `--broadcasts` | | Mix in `ListensToBroadcasts` | +| `--timers` | | Mix in `OwnsTimers` | +| `--validates` | | Mix in `ValidatesRequests` and `CollapsesIndexedErrorKeys` | +| `--test` | | Also scaffold `test/app/controllers/_controller_test.dart` | + +Mixins are always assembled in the same fixed order regardless of the order the flags were passed: `ListensToBroadcasts`, `OwnsTimers`, `RunsActions`, then `ValidatesRequests, CollapsesIndexedErrorKeys` (the latter must follow the former, since it is constrained `on ValidatesRequests`). **Output**: `lib/app/controllers/monitor_controller.dart` @@ -202,27 +224,37 @@ dart run magic:artisan make:controller Admin/Dashboard # Nested path ```dart import 'package:magic/magic.dart'; -class MonitorController extends MagicController { - // TODO: Implement controller logic - Widget index() => WDiv(children: []); +class MonitorController extends MagicController implements SessionScoped { + static MonitorController get instance => Magic.findOrPut(MonitorController.new); + + @override + Future resetForSession() async { + // TODO: clear cached state, then refetch for the new session. + } } ``` -**Resource controller stub** (with `--resource`): Includes full CRUD methods — `index()`, `create()`, `show()`, `edit()`, `store()`, `update()`, `destroy()` — with API integration scaffolding. +**Resource controller stub** (with `--resource`): owns a `RepositoryQuery` over `MonitorRepository.instance`, exposes `items`, `ensureFresh()` and `reload()`, starts the first read from `onInit()`, disposes the query from `onClose()`, and `resetForSession()` resets the repository and reloads the query. ### `dart run magic:artisan make:view` -Creates a view widget. +Creates a view class: a plain `StatelessWidget` with no backing controller, or a `MagicStatefulView` wired to one. ```bash -dart run magic:artisan make:view Login -dart run magic:artisan make:view Login --stateful # With initState/dispose lifecycle -dart run magic:artisan make:view Auth/Register # Nested path +dart run magic:artisan make:view Login # StatelessWidget +dart run magic:artisan make:view Auth/Register # Nested path +dart run magic:artisan make:view Monitor --controller=Monitor # MagicStatefulView +dart run magic:artisan make:view Login --stateful # Controller derived: LoginController +dart run magic:artisan make:view Monitors/List --controller=Monitor --list --form=MonitorFormObject ``` | Flag | Effect | |:-----|:-------| -| `--stateful` | Generate `StatefulWidget` with `initState()` and `dispose()` lifecycle hooks | +| `--stateful` | Generate a stateful view with lifecycle hooks | +| `--controller=Name` | Bind the view to this controller (suffix optional); implies `--stateful` | +| `--list` | Add `RefetchesOnMount` so the view reloads on every mount; expects a controller made with `make:controller --resource` (its `ensureFresh()`) | +| `--form=Name` | Add a `State`-owned form object field (suffix optional), disposed in `onClose`; implies `--stateful` | +| `--test` | Also scaffold `test/resources/views/_view_test.dart` | **Output**: `lib/resources/views/login_view.dart` @@ -241,7 +273,7 @@ class LoginView extends StatelessWidget { } ``` -**Stateful stub** (with `--stateful`): Generates `StatefulWidget` with `initState()` for resource setup and `dispose()` for cleanup. +**Stateful stub** (`--stateful`, or implied by `--controller`/`--form`): a `MagicStatefulView` whose `State` registers the controller via `Magic.findOrPut` in `initState()`, disposes the `--form` object in `onClose()`, and (with `--list`) overrides `refetch()` to call `controller.ensureFresh()`. ### `dart run magic:artisan make:migration` @@ -291,8 +323,13 @@ Creates a string-backed enum with `fromValue()` lookup and `selectOptions` gette ```bash dart run magic:artisan make:enum MonitorStatus dart run magic:artisan make:enum Status/OrderStatus # Nested path +dart run magic:artisan make:enum IncidentSeverity --wire # Wire-backed enum ``` +| Flag | Effect | +|:-----|:-------| +| `--wire` | Generate a wire-backed enum instead: an `unknown` fallback case, a `fromWire()` factory that never throws on an unrecognised value, and a `trans()`-backed `label` getter | + **Output**: `lib/app/enums/monitor_status.dart` **Generated stub:** @@ -526,8 +563,13 @@ Creates a form request class for validation. ```bash dart run magic:artisan make:request StoreMonitor # Auto-appends 'Request' dart run magic:artisan make:request StoreMonitorRequest # No double-suffix +dart run magic:artisan make:request StoreMonitor --test # + matching test ``` +| Flag | Effect | +|:-----|:-------| +| `--test` | Also scaffold `test/app/validation/requests/_request_test.dart` | + **Output**: `lib/app/validation/requests/store_monitor_request.dart` **Generated stub:** @@ -553,8 +595,15 @@ Creates a JSON language/translation file. ```bash dart run magic:artisan make:lang tr dart run magic:artisan make:lang en +dart run magic:artisan make:lang tr --from=en # copy en.json's key tree into tr.json ``` +| Flag | Effect | +|:-----|:-------| +| `--from=locale` | Source locale to copy the key tree from (default `en`) | + +When `assets/lang/.json` exists, writes `assets/lang/.json` with the SAME key tree, each leaf copied verbatim from the source (a complete catalogue ready for a human translator). Otherwise writes an empty translation map (`{}`). + **Output**: `assets/lang/tr.json` Language codes must match the asset path convention. Ensure the `assets/lang/` directory is declared in `pubspec.yaml`: @@ -568,7 +617,7 @@ flutter: ### `dart run magic:artisan make:component` -Scaffolds an atomic 4-file component folder under `lib/ui/components//`. +Scaffolds an atomic component folder under `lib/ui/components//`, plus its widget test at `test/ui/components//_test.dart` (the barrel imported with a prefix, so a name like `Badge` stays unambiguous). ```bash dart run magic:artisan make:component Avatar @@ -576,16 +625,114 @@ dart run magic:artisan make:component Avatar --variants=intent,size dart run magic:artisan make:component Panel --slots ``` -**Output** (for `Avatar`): `lib/ui/components/avatar/avatar.dart` (`class Avatar`, unprefixed PascalCase), `avatar.recipe.dart` (a `WindRecipe`, or a `WindSlotRecipe` under `--slots`, seeded with the requested `--variants` axes), `avatar.preview.dart` (a single public `AvatarPreview` matrix), and `index.dart` (re-exports the component + recipe, NOT the preview). +**Output** (for `Avatar`): `lib/ui/components/avatar/avatar.dart` (`class Avatar`, unprefixed PascalCase), `avatar.recipe.dart` (a `WindRecipe`, or a `WindSlotRecipe` under `--slots`, seeded with the requested `--variants` axes), `index.dart` (re-exports the component + recipe, NOT the preview), and conditionally `avatar.preview.dart` (a single public `AvatarPreview` matrix). -After scaffolding, the command chains `previews:refresh` so the new preview lands in `_previews.g.dart` automatically. +The preview file (`avatar.preview.dart`) and the chained `previews:refresh` are only scaffolded when the target project already maintains a preview catalogue: any `*.preview.dart` file or a `_previews.g.dart` index anywhere under `lib/`. `--preview`/`--no-preview` override the auto-detection in either direction. | Flag | Effect | |:-----|:-------| | `--variants=a,b` | Seeds the named variant axes into the recipe (values left empty to fill in). | | `--slots` | Scaffolds a multi-part `WindSlotRecipe` instead of a single-element `WindRecipe`. | +| `--preview` / `--no-preview` | Force the preview file (and `previews:refresh`) on/off, overriding catalogue auto-detection. | | `--force` | Overwrite an existing component. | +`make:component` also always scaffolds the matching widget test at `test/ui/components//_test.dart` (skipped with a note when the project has no `pubspec.yaml` to read a package name from). + + +### `dart run magic:artisan make:repository` + +Creates a `Repository` subclass caching one REST resource's rows by id (Eloquent's identity map). + +```bash +dart run magic:artisan make:repository Monitor # Auto-appends 'Repository' +dart run magic:artisan make:repository MonitorRepository # No double-suffix +dart run magic:artisan make:repository Monitor --test # + matching test +``` + +| Flag | Effect | +|:-----|:-------| +| `--test` | Also scaffold `test/app/repositories/_repository_test.dart` | + +**Output**: `lib/app/repositories/monitor_repository.dart` + +### `dart run magic:artisan make:action` + +Creates a `MagicAction` subclass in `lib/app/actions/`. Plain (no `--kind`) scaffolds the default stateless skeleton (`MagicAction`); `--kind` with `--model` scaffolds one of the three write variants against that model. + +```bash +dart run magic:artisan make:action PauseMonitor +dart run magic:artisan make:action Monitors/PauseMonitor # Nested path +dart run magic:artisan make:action Monitors/CreateMonitor --kind=create --model=Monitor +dart run magic:artisan make:action Monitors/PauseMonitor --test # + matching test +``` + +| Flag | Effect | +|:-----|:-------| +| `--kind=create\|update\|delete` | The write variant to scaffold; requires `--model` | +| `--model=Name` | The model this action writes; required alongside `--kind` | +| `--test` | Also scaffold `test/app/actions/_test.dart` | + +The kinds: `create` fills and saves a new model; `update` takes `({String id, Map fields})`, saves, and writes the row into `Repository` via `upsertFromShow`; `delete` deletes and evicts the row. A refused save throws `ActionRequestFailed.refusalOf(...)`. + +**Output**: `lib/app/actions/pause_monitor.dart` + +### `dart run magic:artisan make:form` + +Creates a `MagicFormObject` subclass in `lib/app/forms/`. The `FormObject` suffix is deliberate: apps already name form widgets `Form`, so the object backing one needs a distinct name. `--resource=` scaffolds the full create/edit contract instead of the plain skeleton: an `editing` field, `initial` seeded from `editing?.toArray()`, `request` choosing between the model's Store/Update requests, and `persist` running the matching Create/Update action. A create then calls `Controller.instance.reload()`, so the form expects the `--resource` controller `make:resource` writes beside it. + +```bash +dart run magic:artisan make:form Monitor # → MonitorFormObject +dart run magic:artisan make:form Monitor --request=StoreMonitorRequest +dart run magic:artisan make:form Monitor --resource=Monitor # Full create/edit contract +dart run magic:artisan make:form Monitor --test # + matching test +``` + +| Flag | Effect | +|:-----|:-------| +| `--request=Class` | The `FormRequest` class this form validates against | +| `--resource=Model` | The model this form creates/edits; scaffolds the full `editing` + Store/Update + `persist` contract | +| `--test` | Also scaffold `test/app/forms/_test.dart` | + +**Output**: `lib/app/forms/monitor_form_object.dart` + +### `dart run magic:artisan make:test` + +Scaffolds a test skeleton mirroring where a `make:*` generator's own output lives (Laravel's `make:test`, ported to magic's `lib/` → `test/` layout). + +```bash +dart run magic:artisan make:test Monitor --kind=controller +dart run magic:artisan make:test Monitors/PauseMonitor --kind=action # Nested path +dart run magic:artisan make:test Monitor --kind=repository --force # Overwrite +``` + +| Flag | Effect | +|:-----|:-------| +| `--kind=controller\|action\|form\|repository\|request\|view\|unit` | The generator kind whose output this test mirrors | + +The generated test imports the target class as `package:/...`, where `` is read from the target project's own `pubspec.yaml` (relative `../lib/` imports would trip `avoid_relative_lib_imports`). `unit` has no source-file counterpart; its stub only substitutes `{{ packageName }}` inside a TODO comment. + +**Output**: e.g. `test/app/controllers/monitor_controller_test.dart` for `--kind=controller`. + +### `dart run magic:artisan make:resource` + +Magic's analogue of Laravel's `make:model --all`: composes the owning generators into one CRUD vertical for a model (model and factory, repository, the create/update/delete actions, the Store and Update requests, the resource form object, a `--resource --actions` controller, and the list and form views, plus the tests for the actions, the form and the controller). It writes no file itself; every file comes from its own generator. + +```bash +dart run magic:artisan make:resource Monitor +dart run magic:artisan make:resource Monitor --no-views # Data and logic layers only +dart run magic:artisan make:resource Monitor --no-model # The model already exists +dart run magic:artisan make:resource Monitor --force # Overwrite the clashes +``` + +| Flag | Effect | +|:-----|:-------| +| `--views` / `--no-views` | Generate the list and form views (default on) | +| `--model` / `--no-model` | Generate the model and its factory (default on) | +| `--force` | Overwrite any clashing file (a hand-edited model or factory is kept regardless) | + +The run is all-or-nothing: every target path is preflighted before anything is written. A present model or factory is kept (never regenerated, even under `--force`); any other present file fails the run (exit 1, nothing written) unless `--force` is passed. The index and create route lines are printed for pasting into `RouteServiceProvider.boot()`, never written into it. + +**Output** (for `Monitor`, all defaults): `lib/app/models/monitor.dart`, `lib/database/factories/monitor_factory.dart`, `lib/app/repositories/monitor_repository.dart`, `lib/app/actions/monitors/{create,update,delete}_monitor.dart` (+ tests), `lib/app/validation/requests/{store,update}_monitor_request.dart`, `lib/app/forms/monitor_form_object.dart` (+ test), `lib/app/controllers/monitor_controller.dart` (+ test), `lib/resources/views/monitors/{monitors_list_view,monitor_form_view}.dart`. ### `dart run magic:artisan previews:refresh` @@ -680,11 +827,30 @@ dart run magic:artisan make:model Monitor -c --all dart run magic:artisan make:controller Monitor --resource --model=Monitor ``` +### Scaffolding a Full CRUD Vertical with `make:resource` + +`make:resource` composes the same shape by hand: model + factory, repository, the create/update/delete actions, the Store/Update requests, the resource form object, a `--resource --actions` controller, and the list/form views, plus their tests: + +```bash +dart run magic:artisan make:resource Monitor +``` + +Skip the data layer when the model already exists, or the views for an API-only vertical: + +```bash +dart run magic:artisan make:resource Monitor --no-model +dart run magic:artisan make:resource Monitor --no-views +``` + +The run is all-or-nothing: it preflights every target path first, so a clash anywhere leaves the project untouched unless `--force` is passed. + ## Gotchas 1. **Project root**: All commands resolve paths relative to `pubspec.yaml`. Run from the Flutter project root. -2. **Auto-suffixes**: Commands like `make:controller`, `make:factory`, `make:seeder`, `make:provider`, `make:policy`, `make:request` auto-append suffixes. Providing existing suffixes does not create doubles (e.g., `make:controller MonitorController` creates one file, not `MonitorControllerController`). -3. **No rollback**: `make:model -mcf` generates multiple files independently. If one generator fails, others still create files. +2. **Auto-suffixes**: Commands like `make:controller`, `make:factory`, `make:seeder`, `make:provider`, `make:policy`, `make:request`, `make:repository`, `make:form` auto-append suffixes. Providing existing suffixes does not create doubles (e.g., `make:controller MonitorController` creates one file, not `MonitorControllerController`). +3. **No rollback**: `make:model -mcf` generates multiple files independently. If one generator fails, others still create files. `make:resource` is the exception: it preflights every target path before writing anything, so a clash fails the whole run with nothing written. 4. **Nested paths**: Use forward slashes to create subdirectories: `Admin/Dashboard` → `admin/dashboard_controller.dart`. 5. **Lang codes**: `make:lang` codes must match `assets/lang/{code}.json` convention. Declare the directory in `pubspec.yaml` assets. 6. **Timestamp migrations**: `make:migration` automatically prepends `m_YYYYMMDDHHMMSS_` prefix. Always run from your project root so timestamps are consistent. +7. **`--test` needs a package name**: the chained `make:test` (and `make:component`'s own matching test) resolve the target project's package name from `pubspec.yaml`'s `name:` key; a project without one skips the test with a printed note rather than failing the whole command. +8. **`make:action --kind`**: always requires `--model` (the model the write acts on); the command refuses to write with `--kind` and no `--model`. diff --git a/test/actions/action_request_failed_test.dart b/test/actions/action_request_failed_test.dart new file mode 100644 index 00000000..907c6c58 --- /dev/null +++ b/test/actions/action_request_failed_test.dart @@ -0,0 +1,78 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/magic.dart'; + +void main() { + group('ActionRequestFailed.refusalOf', () { + test('answers a ValidationException with the first message per field ' + 'when errors is non-empty', () { + final result = ActionRequestFailed.refusalOf('pause', { + 'status': ['must not be paused', 'is invalid'], + 'id': ['is required'], + }); + + expect(result, isA()); + final validation = result as ValidationException; + expect(validation.errors['status'], 'must not be paused'); + expect(validation.errors['id'], 'is required'); + }); + + test('answers an ActionRequestFailed carrying the response when errors ' + 'is empty', () { + final response = MagicResponse(data: null, statusCode: 500); + + final result = ActionRequestFailed.refusalOf('pause', {}, response); + + expect(result, isA()); + final failed = result as ActionRequestFailed; + expect(failed.action, 'pause'); + expect(failed.response, same(response)); + }); + }); + + group('ActionRequestFailed.retryAfterSeconds', () { + test('reads retry_after_seconds from a 429 response body', () { + final response = MagicResponse( + data: {'retry_after_seconds': 42}, + statusCode: 429, + ); + final failure = ActionRequestFailed('pause', response); + + expect(failure.retryAfterSeconds, 42); + }); + + test('falls back to 1 when the body carries no usable value', () { + final withoutBody = ActionRequestFailed('pause', null); + final withNonMapBody = ActionRequestFailed( + 'pause', + MagicResponse(data: 'not a map', statusCode: 429), + ); + final withMissingKey = ActionRequestFailed( + 'pause', + MagicResponse(data: {}, statusCode: 429), + ); + + expect(withoutBody.retryAfterSeconds, 1); + expect(withNonMapBody.retryAfterSeconds, 1); + expect(withMissingKey.retryAfterSeconds, 1); + }); + }); + + group('ActionRequestFailed.message', () { + test('withMessage wins over the response message', () { + final response = MagicResponse( + data: {'message': 'from the response'}, + statusCode: 500, + ); + final withMessage = ActionRequestFailed.withMessage( + 'pause', + 'from withMessage', + ); + + expect(withMessage.message, 'from withMessage'); + expect( + ActionRequestFailed('pause', response).message, + 'from the response', + ); + }); + }); +} diff --git a/test/cli/commands/_harness.dart b/test/cli/commands/_harness.dart new file mode 100644 index 00000000..955f3a5e --- /dev/null +++ b/test/cli/commands/_harness.dart @@ -0,0 +1,15 @@ +import 'package:fluttersdk_artisan/artisan.dart'; + +/// Shared test harness for `make:*` generator command tests. +/// +/// Builds a bare [ArtisanContext] driving [command] with [args]: parses +/// [args] against the command's own [ArgParser] (via [command.configure]) +/// and wraps the result in an [ArgvInput], the same shape every +/// `make_*_command_test.dart` file hand-rolled as a local `_ctx` helper +/// (see `make_action_command_test.dart`). +ArtisanContext buildCommandContext(ArtisanCommand command, List args) { + final parser = ArgParser(); + command.configure(parser); + final input = ArgvInput.parse(parser, args); + return ArtisanContext.bare(input, BufferedOutput()); +} diff --git a/test/cli/commands/make_action_command_test.dart b/test/cli/commands/make_action_command_test.dart index 82a5ecf1..ca6fc234 100644 --- a/test/cli/commands/make_action_command_test.dart +++ b/test/cli/commands/make_action_command_test.dart @@ -5,13 +5,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:magic/src/cli/commands/make_action_command.dart'; import 'package:path/path.dart' as p; -/// A bare [ArtisanContext] driving the command with [args]. -ArtisanContext _ctx(MakeActionCommand cmd, List args) { - final parser = ArgParser(); - cmd.configure(parser); - final input = ArgvInput.parse(parser, args); - return ArtisanContext.bare(input, BufferedOutput()); -} +import '_harness.dart'; void main() { group('MakeActionCommand metadata', () { @@ -43,7 +37,9 @@ void main() { test('scaffolds an action extending MagicAction', () async { final cmd = MakeActionCommand(testRoot: projectRoot.path); - final code = await cmd.handle(_ctx(cmd, ['PauseMonitor'])); + final code = await cmd.handle( + buildCommandContext(cmd, ['PauseMonitor']), + ); expect(code, 0); final file = File( @@ -62,7 +58,7 @@ void main() { test('supports a nested name', () async { final cmd = MakeActionCommand(testRoot: projectRoot.path); final code = await cmd.handle( - _ctx(cmd, ['Monitors/PauseMonitor']), + buildCommandContext(cmd, ['Monitors/PauseMonitor']), ); expect(code, 0); @@ -82,17 +78,187 @@ void main() { test('returns 1 when the name argument is missing', () async { final cmd = MakeActionCommand(testRoot: projectRoot.path); - final code = await cmd.handle(_ctx(cmd, [])); + final code = await cmd.handle(buildCommandContext(cmd, [])); expect(code, 1); }); test('returns 1 when the file already exists without --force', () async { final cmd = MakeActionCommand(testRoot: projectRoot.path); - await cmd.handle(_ctx(cmd, ['PauseMonitor'])); + await cmd.handle(buildCommandContext(cmd, ['PauseMonitor'])); final second = MakeActionCommand(testRoot: projectRoot.path); - final code = await second.handle(_ctx(second, ['PauseMonitor'])); + final code = await second.handle( + buildCommandContext(second, ['PauseMonitor']), + ); + expect(code, 1); + }); + + test('--kind=create --model wires a typed create action', () async { + final cmd = MakeActionCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, [ + 'Monitors/CreateMonitor', + '--kind=create', + '--model=Monitor', + ]), + ); + + expect(code, 0); + final content = File( + p.join( + projectRoot.path, + 'lib', + 'app', + 'actions', + 'monitors', + 'create_monitor.dart', + ), + ).readAsStringSync(); + + expect(content, contains("import '../../models/monitor.dart';")); + expect( + content, + contains( + 'class CreateMonitor extends MagicAction, Monitor>', + ), + ); + expect( + content, + contains('Future handle(Map fields)'), + ); + expect(content, contains('ActionRequestFailed.refusalOf(')); + expect(content, contains('monitor.validationErrors')); + }); + + test('--kind=update --model wires a record-input update action', () async { + final cmd = MakeActionCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, [ + 'Monitors/UpdateMonitor', + '--kind=update', + '--model=Monitor', + ]), + ); + + expect(code, 0); + final content = File( + p.join( + projectRoot.path, + 'lib', + 'app', + 'actions', + 'monitors', + 'update_monitor.dart', + ), + ).readAsStringSync(); + + expect(content, contains("import '../../models/monitor.dart';")); + expect( + content, + contains( + 'extends MagicAction<({String id, Map fields}), Monitor?>', + ), + ); + expect(content, contains('await Monitor.find(input.id)')); + expect( + content, + contains("import '../../repositories/monitor_repository.dart';"), + ); + expect( + content, + contains('MonitorRepository.instance.upsertFromShow(monitor)'), + ); + expect(content, contains('ActionRequestFailed.refusalOf(')); + }); + + test( + '--kind=delete --model wires a repository-evicting delete action', + () async { + final cmd = MakeActionCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, [ + 'Monitors/DeleteMonitor', + '--kind=delete', + '--model=Monitor', + ]), + ); + + expect(code, 0); + final content = File( + p.join( + projectRoot.path, + 'lib', + 'app', + 'actions', + 'monitors', + 'delete_monitor.dart', + ), + ).readAsStringSync(); + + expect(content, contains("import '../../models/monitor.dart';")); + expect( + content, + contains("import '../../repositories/monitor_repository.dart';"), + ); + expect( + content, + contains('class DeleteMonitor extends MagicAction'), + ); + expect( + content, + contains("throw ActionRequestFailed('delete \${monitor.id}')"), + ); + expect( + content, + contains("MonitorRepository.instance.evict('\${monitor.id}')"), + ); + }, + ); + + test('returns 1 when --kind is given without --model', () async { + final cmd = MakeActionCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['CreateMonitor', '--kind=create']), + ); expect(code, 1); }); + + test('returns 1 when --kind is unknown', () async { + final cmd = MakeActionCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, [ + 'CreateMonitor', + '--kind=bogus', + '--model=Monitor', + ]), + ); + expect(code, 1); + }); + + test('--test chains make:test --kind=action', () async { + final cmd = MakeActionCommand(testRoot: projectRoot.path); + File( + p.join(projectRoot.path, 'pubspec.yaml'), + ).createSync(recursive: true); + File( + p.join(projectRoot.path, 'pubspec.yaml'), + ).writeAsStringSync('name: fixture_app\n'); + + final code = await cmd.handle( + buildCommandContext(cmd, ['PauseMonitor', '--test']), + ); + + expect(code, 0); + final testFile = File( + p.join( + projectRoot.path, + 'test', + 'app', + 'actions', + 'pause_monitor_test.dart', + ), + ); + expect(testFile.existsSync(), isTrue); + }); }); } diff --git a/test/cli/commands/make_component_command_test.dart b/test/cli/commands/make_component_command_test.dart index 54cd3f03..0aadb2d8 100644 --- a/test/cli/commands/make_component_command_test.dart +++ b/test/cli/commands/make_component_command_test.dart @@ -34,11 +34,27 @@ void main() { late Directory projectRoot; late String stubsDir; + /// Seeds `lib/_previews.g.dart` so preview auto-detection sees an + /// existing catalogue (the default assumption for tests that predate + /// detection and still expect a preview file). + void seedPreviewCatalogue() { + final libDir = Directory(p.join(projectRoot.path, 'lib')) + ..createSync(recursive: true); + File(p.join(libDir.path, '_previews.g.dart')).writeAsStringSync( + "List previewEntries() => const [];\n", + ); + } + setUp(() { projectRoot = Directory.systemTemp.createTempSync('make_component_'); // The real stubs ship in magic's assets/stubs/; point the generator at // them via the env override the StubLoader honours. stubsDir = p.join(Directory.current.path, 'assets', 'stubs'); + // The generator always attempts to write the matching widget test, + // which reads the target project's package name from pubspec.yaml. + File( + p.join(projectRoot.path, 'pubspec.yaml'), + ).writeAsStringSync('name: app\n'); }); tearDown(() { @@ -46,6 +62,7 @@ void main() { }); test('scaffolds the 4-file atomic component folder', () async { + seedPreviewCatalogue(); final cmd = MakeComponentCommand(testRoot: projectRoot.path); final code = await cmd.handle( _ctx(cmd, ['Avatar', '--stubs-dir=$stubsDir']), @@ -60,6 +77,7 @@ void main() { }); test('names the component class unprefixed PascalCase', () async { + seedPreviewCatalogue(); final cmd = MakeComponentCommand(testRoot: projectRoot.path); await cmd.handle(_ctx(cmd, ['Avatar', '--stubs-dir=$stubsDir'])); @@ -214,5 +232,229 @@ class SeedPreview extends StatelessWidget { expect(generated.existsSync(), isTrue); expect(generated.readAsStringSync(), contains('AvatarPreview')); }); + + group('preview auto-detection', () { + String avatarPreviewPath() => p.join( + projectRoot.path, + 'lib', + 'ui', + 'components', + 'avatar', + 'avatar.preview.dart', + ); + + test('writes no preview and no _previews.g.dart when lib/ carries no ' + 'catalogue', () async { + final cmd = MakeComponentCommand(testRoot: projectRoot.path); + final output = BufferedOutput(); + final parser = ArgParser(); + cmd.configure(parser); + final input = ArgvInput.parse(parser, [ + 'Avatar', + '--stubs-dir=$stubsDir', + ]); + final code = await cmd.handle(ArtisanContext.bare(input, output)); + + expect(code, 0); + expect(File(avatarPreviewPath()).existsSync(), isFalse); + expect( + File( + p.join(projectRoot.path, 'lib', '_previews.g.dart'), + ).existsSync(), + isFalse, + ); + expect(output.content, contains('Preview: skipped')); + }); + + test( + 'writes a preview when lib/_previews.g.dart already exists', + () async { + seedPreviewCatalogue(); + final cmd = MakeComponentCommand(testRoot: projectRoot.path); + final output = BufferedOutput(); + final parser = ArgParser(); + cmd.configure(parser); + final input = ArgvInput.parse(parser, [ + 'Avatar', + '--stubs-dir=$stubsDir', + ]); + final code = await cmd.handle(ArtisanContext.bare(input, output)); + + expect(code, 0); + expect(File(avatarPreviewPath()).existsSync(), isTrue); + expect(output.content, contains('Preview: written')); + }, + ); + + test( + 'writes a preview when a nested *.preview.dart file already exists', + () async { + final nested = Directory( + p.join(projectRoot.path, 'lib', 'ui', 'components', 'other'), + )..createSync(recursive: true); + File(p.join(nested.path, 'other.preview.dart')).writeAsStringSync(''' +import 'package:flutter/widgets.dart'; + +class OtherPreview extends StatelessWidget { + const OtherPreview({super.key}); + @override + Widget build(BuildContext context) => const SizedBox.shrink(); +} +'''); + + final cmd = MakeComponentCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + _ctx(cmd, ['Avatar', '--stubs-dir=$stubsDir']), + ); + + expect(code, 0); + expect(File(avatarPreviewPath()).existsSync(), isTrue); + }, + ); + + test('--no-preview beats an existing catalogue', () async { + seedPreviewCatalogue(); + final cmd = MakeComponentCommand(testRoot: projectRoot.path); + final output = BufferedOutput(); + final parser = ArgParser(); + cmd.configure(parser); + final input = ArgvInput.parse(parser, [ + 'Avatar', + '--no-preview', + '--stubs-dir=$stubsDir', + ]); + final code = await cmd.handle(ArtisanContext.bare(input, output)); + + expect(code, 0); + expect(File(avatarPreviewPath()).existsSync(), isFalse); + expect(output.content, contains('Preview: skipped')); + expect(output.content, contains('--no-preview')); + }); + + test('--preview beats an absent catalogue', () async { + final cmd = MakeComponentCommand(testRoot: projectRoot.path); + final output = BufferedOutput(); + final parser = ArgParser(); + cmd.configure(parser); + final input = ArgvInput.parse(parser, [ + 'Avatar', + '--preview', + '--stubs-dir=$stubsDir', + ]); + final code = await cmd.handle(ArtisanContext.bare(input, output)); + + expect(code, 0); + expect(File(avatarPreviewPath()).existsSync(), isTrue); + expect(output.content, contains('Preview: written')); + expect(output.content, contains('--preview')); + }); + }); + + group('matching widget test', () { + String testFilePath() => p.join( + projectRoot.path, + 'test', + 'ui', + 'components', + 'avatar', + 'avatar_test.dart', + ); + + test('writes test/ui/components//_test.dart importing ' + 'packageName', () async { + final cmd = MakeComponentCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + _ctx(cmd, ['Avatar', '--stubs-dir=$stubsDir']), + ); + + expect(code, 0); + final file = File(testFilePath()); + expect(file.existsSync(), isTrue); + final content = file.readAsStringSync(); + expect( + content, + contains("package:app/ui/components/avatar/index.dart"), + ); + expect(content, contains('Avatar')); + }); + + test('refuses a kept test without --force and writes nothing', () async { + File(testFilePath()) + ..createSync(recursive: true) + ..writeAsStringSync('// filled in by hand\n'); + + final cmd = MakeComponentCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + _ctx(cmd, ['Avatar', '--stubs-dir=$stubsDir']), + ); + + expect(code, 1); + expect( + File(testFilePath()).readAsStringSync(), + '// filled in by hand\n', + ); + expect( + File( + p.join( + projectRoot.path, + 'lib', + 'ui', + 'components', + 'avatar', + 'avatar.dart', + ), + ).existsSync(), + isFalse, + ); + }); + + test('--force overwrites a kept test', () async { + File(testFilePath()) + ..createSync(recursive: true) + ..writeAsStringSync('// filled in by hand\n'); + + final cmd = MakeComponentCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + _ctx(cmd, ['Avatar', '--force', '--stubs-dir=$stubsDir']), + ); + + expect(code, 0); + expect( + File(testFilePath()).readAsStringSync(), + contains('package:app/ui/components/avatar/index.dart'), + ); + }); + + test('works for a --slots component too', () async { + final cmd = MakeComponentCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + _ctx(cmd, ['Avatar', '--slots', '--stubs-dir=$stubsDir']), + ); + + expect(code, 0); + expect(File(testFilePath()).existsSync(), isTrue); + }); + + test( + 'skips the test file with a printed note when pubspec.yaml is absent', + () async { + File(p.join(projectRoot.path, 'pubspec.yaml')).deleteSync(); + + final cmd = MakeComponentCommand(testRoot: projectRoot.path); + final output = BufferedOutput(); + final parser = ArgParser(); + cmd.configure(parser); + final input = ArgvInput.parse(parser, [ + 'Avatar', + '--stubs-dir=$stubsDir', + ]); + final code = await cmd.handle(ArtisanContext.bare(input, output)); + + expect(code, 0); + expect(File(testFilePath()).existsSync(), isFalse); + expect(output.content, contains('pubspec.yaml')); + }, + ); + }); }); } diff --git a/test/cli/commands/make_controller_command_test.dart b/test/cli/commands/make_controller_command_test.dart new file mode 100644 index 00000000..f8bb8916 --- /dev/null +++ b/test/cli/commands/make_controller_command_test.dart @@ -0,0 +1,292 @@ +import 'dart:io'; + +import 'package:fluttersdk_artisan/artisan.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/src/cli/commands/make_controller_command.dart'; +import 'package:path/path.dart' as p; + +import '_harness.dart'; + +void main() { + group('MakeControllerCommand metadata', () { + final cmd = MakeControllerCommand(); + + test('declares name make:controller', () { + expect(cmd.name, 'make:controller'); + }); + + test('extends ArtisanGeneratorCommand', () { + expect(cmd, isA()); + }); + + test('declares CommandBoot.none', () { + expect(cmd.boot, CommandBoot.none); + }); + }); + + group('MakeControllerCommand.handle()', () { + late Directory projectRoot; + + setUp(() { + projectRoot = Directory.systemTemp.createTempSync('make_controller_'); + }); + + tearDown(() { + if (projectRoot.existsSync()) projectRoot.deleteSync(recursive: true); + }); + + File controllerFile(String fileStem) => File( + p.join(projectRoot.path, 'lib', 'app', 'controllers', '$fileStem.dart'), + ); + + test( + 'a plain controller implements SessionScoped and owns no Widget methods', + () async { + final cmd = MakeControllerCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['Monitor']), + ); + + expect(code, 0); + final content = controllerFile('monitor_controller').readAsStringSync(); + expect( + content, + contains('class MonitorController extends MagicController'), + ); + expect(content, contains('implements SessionScoped')); + expect(content, contains('static MonitorController get instance')); + expect(content, contains('Future resetForSession()')); + expect(content, isNot(contains('Widget index('))); + expect(content, isNot(contains('Widget show('))); + }, + ); + + test( + '--resource --model=Monitor owns a RepositoryQuery and ensureFresh()', + () async { + final cmd = MakeControllerCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, [ + 'Uptime', + '--resource', + '--model=Monitor', + ]), + ); + + expect(code, 0); + final content = controllerFile('uptime_controller').readAsStringSync(); + expect(content, contains('RepositoryQuery')); + expect(content, contains('MonitorRepository.instance')); + expect( + content, + contains('Future ensureFresh() => _query.ensureFresh();'), + ); + expect(content, contains('Future reload() => _query.reload();')); + expect(content, contains("import '../models/monitor.dart';")); + expect( + content, + contains("import '../repositories/monitor_repository.dart';"), + ); + }, + ); + + test( + '--resource with no --model defaults the model to the controller name', + () async { + final cmd = MakeControllerCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['Monitor', '--resource']), + ); + + expect(code, 0); + final content = controllerFile('monitor_controller').readAsStringSync(); + expect(content, contains('RepositoryQuery')); + expect(content, contains("import '../models/monitor.dart';")); + expect( + content, + contains("import '../repositories/monitor_repository.dart';"), + ); + }, + ); + + test('--resource on a nested name imports from the right depth', () async { + final cmd = MakeControllerCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['Admin/Dashboard', '--resource']), + ); + + expect(code, 0); + final file = File( + p.join( + projectRoot.path, + 'lib', + 'app', + 'controllers', + 'admin', + 'dashboard_controller.dart', + ), + ); + expect(file.existsSync(), isTrue); + final content = file.readAsStringSync(); + expect(content, contains("import '../../models/dashboard.dart';")); + expect( + content, + contains("import '../../repositories/dashboard_repository.dart';"), + ); + }); + + test('--actions mixes in RunsActions', () async { + final cmd = MakeControllerCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['Monitor', '--actions']), + ); + + expect(code, 0); + final content = controllerFile('monitor_controller').readAsStringSync(); + expect(content, contains('with RunsActions')); + }); + + test( + '--broadcasts mixes in ListensToBroadcasts with an empty listeners map', + () async { + final cmd = MakeControllerCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['Monitor', '--broadcasts']), + ); + + expect(code, 0); + final content = controllerFile('monitor_controller').readAsStringSync(); + expect(content, contains('with ListensToBroadcasts')); + expect( + content, + contains('Map get listeners'), + ); + }, + ); + + test('--timers mixes in OwnsTimers', () async { + final cmd = MakeControllerCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['Monitor', '--timers']), + ); + + expect(code, 0); + final content = controllerFile('monitor_controller').readAsStringSync(); + expect(content, contains('with OwnsTimers')); + }); + + test( + '--validates mixes in ValidatesRequests and CollapsesIndexedErrorKeys', + () async { + final cmd = MakeControllerCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['Monitor', '--validates']), + ); + + expect(code, 0); + final content = controllerFile('monitor_controller').readAsStringSync(); + expect( + content, + contains('with ValidatesRequests, CollapsesIndexedErrorKeys'), + ); + }, + ); + + test( + 'all four mixin flags together produce one with clause in a stable order ' + 'regardless of the order they were passed', + () async { + final cmd = MakeControllerCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, [ + 'Monitor', + '--validates', + '--actions', + '--timers', + '--broadcasts', + ]), + ); + + expect(code, 0); + final content = controllerFile('monitor_controller').readAsStringSync(); + expect( + content, + contains( + 'with ListensToBroadcasts, OwnsTimers, RunsActions, ValidatesRequests, ' + 'CollapsesIndexedErrorKeys', + ), + ); + }, + ); + + test('--test writes the matching controller test', () async { + File( + p.join(projectRoot.path, 'pubspec.yaml'), + ).writeAsStringSync('name: fixture_app\n'); + + final cmd = MakeControllerCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['Monitor', '--test']), + ); + + expect(code, 0); + final testFile = File( + p.join( + projectRoot.path, + 'test', + 'app', + 'controllers', + 'monitor_controller_test.dart', + ), + ); + expect(testFile.existsSync(), isTrue); + expect( + testFile.readAsStringSync(), + contains( + "import 'package:fixture_app/app/controllers/monitor_controller.dart';", + ), + ); + }); + + test('--test propagates a refused test write as exit 1', () async { + File( + p.join(projectRoot.path, 'pubspec.yaml'), + ).writeAsStringSync('name: fixture_app\n'); + File( + p.join( + projectRoot.path, + 'test', + 'app', + 'controllers', + 'monitor_controller_test.dart', + ), + ) + ..createSync(recursive: true) + ..writeAsStringSync('// hand-written\n'); + + final cmd = MakeControllerCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['Monitor', '--test']), + ); + + expect(code, 1); + }); + + test('returns 1 when the name argument is missing', () async { + final cmd = MakeControllerCommand(testRoot: projectRoot.path); + final code = await cmd.handle(buildCommandContext(cmd, [])); + expect(code, 1); + }); + + test('returns 1 when the file already exists without --force', () async { + final cmd = MakeControllerCommand(testRoot: projectRoot.path); + await cmd.handle(buildCommandContext(cmd, ['Monitor'])); + + final second = MakeControllerCommand(testRoot: projectRoot.path); + final code = await second.handle( + buildCommandContext(second, ['Monitor']), + ); + expect(code, 1); + }); + }); +} diff --git a/test/cli/commands/make_enum_command_test.dart b/test/cli/commands/make_enum_command_test.dart new file mode 100644 index 00000000..ed8e2db0 --- /dev/null +++ b/test/cli/commands/make_enum_command_test.dart @@ -0,0 +1,105 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttersdk_artisan/artisan.dart'; +import 'package:magic/src/cli/commands/make_enum_command.dart'; +import 'package:path/path.dart' as p; + +import '_harness.dart'; + +void main() { + group('MakeEnumCommand metadata', () { + final cmd = MakeEnumCommand(); + + test('declares name make:enum', () { + expect(cmd.name, 'make:enum'); + }); + + test('declares CommandBoot.none', () { + expect(cmd.boot, CommandBoot.none); + }); + }); + + group('MakeEnumCommand.handle()', () { + late Directory projectRoot; + + setUp(() { + projectRoot = Directory.systemTemp.createTempSync('make_enum_'); + }); + + tearDown(() { + if (projectRoot.existsSync()) projectRoot.deleteSync(recursive: true); + }); + + test('scaffolds the plain value/label enum by default', () async { + final cmd = MakeEnumCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['MonitorType']), + ); + + expect(code, 0); + final file = File( + p.join(projectRoot.path, 'lib', 'app', 'enums', 'monitor_type.dart'), + ); + expect(file.existsSync(), isTrue); + + final content = file.readAsStringSync(); + expect(content, contains('enum MonitorType {')); + expect(content, contains('static MonitorType? fromValue(String? value)')); + expect(content, isNot(contains('fromWire'))); + }); + + test('scaffolds a wire-backed enum with --wire', () async { + final cmd = MakeEnumCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['IncidentSeverity', '--wire']), + ); + + expect(code, 0); + final file = File( + p.join( + projectRoot.path, + 'lib', + 'app', + 'enums', + 'incident_severity.dart', + ), + ); + expect(file.existsSync(), isTrue); + + final content = file.readAsStringSync(); + expect( + content, + contains('static IncidentSeverity fromWire(Object? value'), + ); + expect(content, contains("trans('enums.incident_severity.")); + expect(content, contains("unknown('unknown')")); + expect(content, contains('final String wire;')); + }); + + test( + 'derives the snake key MonitorRegion -> monitor_region for --wire', + () async { + final cmd = MakeEnumCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['MonitorRegion', '--wire']), + ); + + expect(code, 0); + final file = File( + p.join( + projectRoot.path, + 'lib', + 'app', + 'enums', + 'monitor_region.dart', + ), + ); + expect( + file.readAsStringSync(), + contains("trans('enums.monitor_region."), + ); + }, + ); + }); +} diff --git a/test/cli/commands/make_event_command_test.dart b/test/cli/commands/make_event_command_test.dart new file mode 100644 index 00000000..7e4fd9df --- /dev/null +++ b/test/cli/commands/make_event_command_test.dart @@ -0,0 +1,92 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttersdk_artisan/artisan.dart'; +import 'package:magic/src/cli/commands/make_event_command.dart'; +import 'package:path/path.dart' as p; + +import '_harness.dart'; + +void main() { + group('MakeEventCommand metadata', () { + final cmd = MakeEventCommand(); + + test('declares name make:event', () { + expect(cmd.name, 'make:event'); + }); + + test('extends ArtisanGeneratorCommand', () { + expect(cmd, isA()); + }); + + test('declares CommandBoot.none', () { + expect(cmd.boot, CommandBoot.none); + }); + }); + + group('MakeEventCommand.handle()', () { + late Directory projectRoot; + + setUp(() { + projectRoot = Directory.systemTemp.createTempSync('make_event_'); + }); + + tearDown(() { + if (projectRoot.existsSync()) projectRoot.deleteSync(recursive: true); + }); + + test('scaffolds an event extending MagicEvent', () async { + final cmd = MakeEventCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['UserLoggedIn']), + ); + + expect(code, 0); + final file = File( + p.join(projectRoot.path, 'lib', 'app', 'events', 'user_logged_in.dart'), + ); + expect(file.existsSync(), isTrue); + + final content = file.readAsStringSync(); + expect(content, contains('class UserLoggedIn extends MagicEvent')); + }); + + test('supports a nested name', () async { + final cmd = MakeEventCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['Auth/TokenRefreshed']), + ); + + expect(code, 0); + final file = File( + p.join( + projectRoot.path, + 'lib', + 'app', + 'events', + 'auth', + 'token_refreshed.dart', + ), + ); + expect(file.existsSync(), isTrue); + expect(file.readAsStringSync(), contains('class TokenRefreshed')); + }); + + test('returns 1 when the name argument is missing', () async { + final cmd = MakeEventCommand(testRoot: projectRoot.path); + final code = await cmd.handle(buildCommandContext(cmd, [])); + expect(code, 1); + }); + + test('returns 1 when the file already exists without --force', () async { + final cmd = MakeEventCommand(testRoot: projectRoot.path); + await cmd.handle(buildCommandContext(cmd, ['UserLoggedIn'])); + + final second = MakeEventCommand(testRoot: projectRoot.path); + final code = await second.handle( + buildCommandContext(second, ['UserLoggedIn']), + ); + expect(code, 1); + }); + }); +} diff --git a/test/cli/commands/make_factory_command_test.dart b/test/cli/commands/make_factory_command_test.dart new file mode 100644 index 00000000..0940361b --- /dev/null +++ b/test/cli/commands/make_factory_command_test.dart @@ -0,0 +1,102 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttersdk_artisan/artisan.dart'; +import 'package:magic/src/cli/commands/make_factory_command.dart'; +import 'package:path/path.dart' as p; + +import '_harness.dart'; + +void main() { + group('MakeFactoryCommand metadata', () { + final cmd = MakeFactoryCommand(); + + test('declares name make:factory', () { + expect(cmd.name, 'make:factory'); + }); + + test('extends ArtisanGeneratorCommand', () { + expect(cmd, isA()); + }); + + test('declares CommandBoot.none', () { + expect(cmd.boot, CommandBoot.none); + }); + }); + + group('MakeFactoryCommand.handle()', () { + late Directory projectRoot; + + setUp(() { + projectRoot = Directory.systemTemp.createTempSync('make_factory_'); + }); + + tearDown(() { + if (projectRoot.existsSync()) projectRoot.deleteSync(recursive: true); + }); + + test('scaffolds at the default path with newFactory()', () async { + final cmd = MakeFactoryCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['Monitor']), + ); + + expect(code, 0); + final file = File( + p.join( + projectRoot.path, + 'lib', + 'database', + 'factories', + 'monitor_factory.dart', + ), + ); + expect(file.existsSync(), isTrue); + final content = file.readAsStringSync(); + expect(content, contains('class MonitorFactory extends Factory')); + expect( + content, + contains('Factory newFactory() => MonitorFactory();'), + ); + }); + + test('resolves a nested name to a nested path', () async { + final cmd = MakeFactoryCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['Admin/Monitor']), + ); + + expect(code, 0); + final file = File( + p.join( + projectRoot.path, + 'lib', + 'database', + 'factories', + 'admin', + 'monitor_factory.dart', + ), + ); + expect(file.existsSync(), isTrue); + expect( + file.readAsStringSync(), + contains('Factory newFactory() => MonitorFactory();'), + ); + }); + + test('returns 1 when the name argument is missing', () async { + final cmd = MakeFactoryCommand(testRoot: projectRoot.path); + final code = await cmd.handle(buildCommandContext(cmd, [])); + expect(code, 1); + }); + + test('returns 1 when the file already exists without --force', () async { + final cmd = MakeFactoryCommand(testRoot: projectRoot.path); + await cmd.handle(buildCommandContext(cmd, ['Monitor'])); + final code = await cmd.handle( + buildCommandContext(cmd, ['Monitor']), + ); + expect(code, 1); + }); + }); +} diff --git a/test/cli/commands/make_form_command_test.dart b/test/cli/commands/make_form_command_test.dart index b6b1be49..aa277ccd 100644 --- a/test/cli/commands/make_form_command_test.dart +++ b/test/cli/commands/make_form_command_test.dart @@ -5,13 +5,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:magic/src/cli/commands/make_form_command.dart'; import 'package:path/path.dart' as p; -/// A bare [ArtisanContext] driving the command with [args]. -ArtisanContext _ctx(MakeFormCommand cmd, List args) { - final parser = ArgParser(); - cmd.configure(parser); - final input = ArgvInput.parse(parser, args); - return ArtisanContext.bare(input, BufferedOutput()); -} +import '_harness.dart'; void main() { group('MakeFormCommand metadata', () { @@ -43,7 +37,9 @@ void main() { test('scaffolds a form object with the FormObject suffix', () async { final cmd = MakeFormCommand(testRoot: projectRoot.path); - final code = await cmd.handle(_ctx(cmd, ['Monitor'])); + final code = await cmd.handle( + buildCommandContext(cmd, ['Monitor']), + ); expect(code, 0); final file = File( @@ -72,7 +68,7 @@ void main() { test('does not double-suffix when FormObject is already present', () async { final cmd = MakeFormCommand(testRoot: projectRoot.path); - await cmd.handle(_ctx(cmd, ['MonitorFormObject'])); + await cmd.handle(buildCommandContext(cmd, ['MonitorFormObject'])); final file = File( p.join( @@ -89,7 +85,10 @@ void main() { test('--request wires a real import and request expression', () async { final cmd = MakeFormCommand(testRoot: projectRoot.path); final code = await cmd.handle( - _ctx(cmd, ['Monitor', '--request=StoreMonitorRequest']), + buildCommandContext(cmd, [ + 'Monitor', + '--request=StoreMonitorRequest', + ]), ); expect(code, 0); @@ -120,7 +119,7 @@ void main() { 'without --request leaves a TODO import and an UnimplementedError', () async { final cmd = MakeFormCommand(testRoot: projectRoot.path); - await cmd.handle(_ctx(cmd, ['Monitor'])); + await cmd.handle(buildCommandContext(cmd, ['Monitor'])); final content = File( p.join( @@ -138,8 +137,95 @@ void main() { test('returns 1 when the name argument is missing', () async { final cmd = MakeFormCommand(testRoot: projectRoot.path); - final code = await cmd.handle(_ctx(cmd, [])); + final code = await cmd.handle(buildCommandContext(cmd, [])); expect(code, 1); }); + + test( + '--resource=Monitor wires the editing + Store/Update contract', + () async { + final cmd = MakeFormCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['Monitor', '--resource=Monitor']), + ); + + expect(code, 0); + final content = File( + p.join( + projectRoot.path, + 'lib', + 'app', + 'forms', + 'monitor_form_object.dart', + ), + ).readAsStringSync(); + + expect(content, contains("import '../models/monitor.dart';")); + expect( + content, + contains("import '../actions/monitors/create_monitor.dart';"), + ); + expect( + content, + contains("import '../actions/monitors/update_monitor.dart';"), + ); + expect( + content, + contains( + "import '../validation/requests/store_monitor_request.dart';", + ), + ); + expect( + content, + contains( + "import '../validation/requests/update_monitor_request.dart';", + ), + ); + expect( + content, + contains("import '../controllers/monitor_controller.dart';"), + ); + expect(content, contains('await MonitorController.instance.reload();')); + expect(content, contains("(id: '\${editing.id}', fields: validated)")); + expect(content, contains('final Monitor? editing;')); + expect( + content, + contains('editing?.toArray() ?? const {};'), + ); + expect(content, contains('const StoreMonitorRequest()')); + expect(content, contains('const UpdateMonitorRequest()')); + expect(content, contains('MagicAction.resolve(CreateMonitor.new)')); + expect( + content, + contains('MagicAction.resolve(\n UpdateMonitor.new,'), + ); + }, + ); + + test('--test chains make:test --kind=form', () async { + final cmd = MakeFormCommand(testRoot: projectRoot.path); + File( + p.join(projectRoot.path, 'pubspec.yaml'), + ).createSync(recursive: true); + File( + p.join(projectRoot.path, 'pubspec.yaml'), + ).writeAsStringSync('name: fixture_app\n'); + + final code = await cmd.handle( + buildCommandContext(cmd, ['Monitor', '--test']), + ); + + expect(code, 0); + final testFile = File( + p.join( + projectRoot.path, + 'test', + 'app', + 'forms', + 'monitor_form_object_test.dart', + ), + ); + expect(testFile.existsSync(), isTrue); + }); }); } diff --git a/test/cli/commands/make_lang_command_test.dart b/test/cli/commands/make_lang_command_test.dart new file mode 100644 index 00000000..f0cc1424 --- /dev/null +++ b/test/cli/commands/make_lang_command_test.dart @@ -0,0 +1,225 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttersdk_artisan/artisan.dart'; +import 'package:magic/src/cli/commands/make_lang_command.dart'; +import 'package:path/path.dart' as p; + +import '_harness.dart'; + +/// Flattens a nested JSON map into dot-joined leaf key paths (e.g. +/// `{"common": {"back": "Back"}}` -> `['common.back']`). Used to assert +/// key-tree parity between a source and a generated locale without +/// requiring identical leaf VALUES. +List _flattenKeys(Map map, [String prefix = '']) { + final keys = []; + for (final entry in map.entries) { + final path = prefix.isEmpty ? entry.key : '$prefix.${entry.key}'; + final value = entry.value; + if (value is Map) { + keys.addAll(_flattenKeys(value, path)); + } else { + keys.add(path); + } + } + return keys; +} + +void main() { + group('MakeLangCommand metadata', () { + final cmd = MakeLangCommand(); + + test('declares name make:lang', () { + expect(cmd.name, 'make:lang'); + }); + + test('declares CommandBoot.none', () { + expect(cmd.boot, CommandBoot.none); + }); + }); + + group('MakeLangCommand.handle()', () { + late Directory projectRoot; + + setUp(() { + projectRoot = Directory.systemTemp.createTempSync('make_lang_'); + }); + + tearDown(() { + if (projectRoot.existsSync()) projectRoot.deleteSync(recursive: true); + }); + + /// Seeds `assets/lang/.json` with [content] under [projectRoot]. + void seedLocale(String code, Map content) { + final file = File( + p.join(projectRoot.path, 'assets', 'lang', '$code.json'), + ); + file.parent.createSync(recursive: true); + file.writeAsStringSync(jsonEncode(content)); + } + + test('copies the source key tree with identical flattened paths', () async { + seedLocale('en', { + 'common': {'back': 'Back', 'save': 'Save'}, + 'validation': { + 'required': 'The :attribute field is required.', + }, + }); + + final cmd = MakeLangCommand(testRoot: projectRoot.path); + final code = await cmd.handle(buildCommandContext(cmd, ['tr'])); + + expect(code, 0); + final file = File(p.join(projectRoot.path, 'assets', 'lang', 'tr.json')); + expect(file.existsSync(), isTrue); + + final source = + jsonDecode( + File( + p.join(projectRoot.path, 'assets', 'lang', 'en.json'), + ).readAsStringSync(), + ) + as Map; + final generated = + jsonDecode(file.readAsStringSync()) as Map; + + expect(_flattenKeys(generated), _flattenKeys(source)); + }); + + test( + 'copies each leaf value from the source (no machine translation)', + () async { + seedLocale('en', { + 'common': {'back': 'Back'}, + }); + + final cmd = MakeLangCommand(testRoot: projectRoot.path); + await cmd.handle(buildCommandContext(cmd, ['tr'])); + + final file = File( + p.join(projectRoot.path, 'assets', 'lang', 'tr.json'), + ); + final generated = + jsonDecode(file.readAsStringSync()) as Map; + expect((generated['common'] as Map)['back'], 'Back'); + }, + ); + + test('writes two-space indented JSON', () async { + seedLocale('en', { + 'common': {'back': 'Back'}, + }); + + final cmd = MakeLangCommand(testRoot: projectRoot.path); + await cmd.handle(buildCommandContext(cmd, ['tr'])); + + final file = File(p.join(projectRoot.path, 'assets', 'lang', 'tr.json')); + expect(file.readAsStringSync(), contains(' "common"')); + }); + + test('--from=de with no de.json writes {}', () async { + seedLocale('en', { + 'common': {'back': 'Back'}, + }); + + final cmd = MakeLangCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['tr', '--from=de']), + ); + + expect(code, 0); + final file = File(p.join(projectRoot.path, 'assets', 'lang', 'tr.json')); + expect(file.readAsStringSync(), '{}'); + }); + + test('without a source locale the file is {}', () async { + final cmd = MakeLangCommand(testRoot: projectRoot.path); + final code = await cmd.handle(buildCommandContext(cmd, ['tr'])); + + expect(code, 0); + final file = File(p.join(projectRoot.path, 'assets', 'lang', 'tr.json')); + expect(file.readAsStringSync(), '{}'); + }); + + test('make:lang en with no en.json still writes {}', () async { + final cmd = MakeLangCommand(testRoot: projectRoot.path); + final code = await cmd.handle(buildCommandContext(cmd, ['en'])); + + expect(code, 0); + final file = File(p.join(projectRoot.path, 'assets', 'lang', 'en.json')); + expect(file.readAsStringSync(), '{}'); + }); + + test('returns 1 naming the source when it is not valid JSON', () async { + final source = File( + p.join(projectRoot.path, 'assets', 'lang', 'en.json'), + ); + source.parent.createSync(recursive: true); + source.writeAsStringSync('{"common": '); + + final cmd = MakeLangCommand(testRoot: projectRoot.path); + final parser = ArgParser(); + cmd.configure(parser); + final output = BufferedOutput(); + final code = await cmd.handle( + ArtisanContext.bare(ArgvInput.parse(parser, ['tr']), output), + ); + + expect(code, 1); + expect(output.content, contains('en.json')); + expect( + File( + p.join(projectRoot.path, 'assets', 'lang', 'tr.json'), + ).existsSync(), + isFalse, + ); + }); + + test('refuses to overwrite an existing target without --force', () async { + seedLocale('en', { + 'common': {'back': 'Back'}, + }); + seedLocale('tr', {}); + + final cmd = MakeLangCommand(testRoot: projectRoot.path); + final code = await cmd.handle(buildCommandContext(cmd, ['tr'])); + + expect(code, 1); + }); + + test( + 'make:lang en --from=en is refused by the existing-file check', + () async { + seedLocale('en', { + 'common': {'back': 'Back'}, + }); + + final cmd = MakeLangCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['en', '--from=en']), + ); + + expect(code, 1); + }, + ); + + test('overwrites with --force', () async { + seedLocale('en', { + 'common': {'back': 'Back'}, + }); + seedLocale('tr', {}); + + final cmd = MakeLangCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['tr', '--force']), + ); + + expect(code, 0); + final file = File(p.join(projectRoot.path, 'assets', 'lang', 'tr.json')); + final generated = + jsonDecode(file.readAsStringSync()) as Map; + expect(generated.containsKey('common'), isTrue); + }); + }); +} diff --git a/test/cli/commands/make_listener_command_test.dart b/test/cli/commands/make_listener_command_test.dart new file mode 100644 index 00000000..74b8f435 --- /dev/null +++ b/test/cli/commands/make_listener_command_test.dart @@ -0,0 +1,136 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttersdk_artisan/artisan.dart'; +import 'package:magic/src/cli/commands/make_listener_command.dart'; +import 'package:path/path.dart' as p; + +import '_harness.dart'; + +void main() { + group('MakeListenerCommand metadata', () { + final cmd = MakeListenerCommand(); + + test('declares name make:listener', () { + expect(cmd.name, 'make:listener'); + }); + + test('extends ArtisanGeneratorCommand', () { + expect(cmd, isA()); + }); + + test('declares CommandBoot.none', () { + expect(cmd.boot, CommandBoot.none); + }); + }); + + group('MakeListenerCommand.handle()', () { + late Directory projectRoot; + + setUp(() { + projectRoot = Directory.systemTemp.createTempSync('make_listener_'); + }); + + tearDown(() { + if (projectRoot.existsSync()) projectRoot.deleteSync(recursive: true); + }); + + test( + 'scaffolds a listener extending MagicListener with --event', + () async { + final cmd = MakeListenerCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, [ + 'AuthRestore', + '--event=UserLoggedInEvent', + ]), + ); + + expect(code, 0); + final file = File( + p.join( + projectRoot.path, + 'lib', + 'app', + 'listeners', + 'auth_restore.dart', + ), + ); + expect(file.existsSync(), isTrue); + + final content = file.readAsStringSync(); + expect( + content, + contains( + 'class AuthRestore extends MagicListener', + ), + ); + expect( + content, + contains("import '../events/user_logged_in_event.dart';"), + ); + }, + ); + + test('defaults to MagicEvent when --event is omitted', () async { + final cmd = MakeListenerCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['AuthRestore']), + ); + + expect(code, 0); + final file = File( + p.join( + projectRoot.path, + 'lib', + 'app', + 'listeners', + 'auth_restore.dart', + ), + ); + final content = file.readAsStringSync(); + expect( + content, + contains('class AuthRestore extends MagicListener'), + ); + }); + + test('supports a nested name', () async { + final cmd = MakeListenerCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['Auth/RestoreSession']), + ); + + expect(code, 0); + final file = File( + p.join( + projectRoot.path, + 'lib', + 'app', + 'listeners', + 'auth', + 'restore_session.dart', + ), + ); + expect(file.existsSync(), isTrue); + expect(file.readAsStringSync(), contains('class RestoreSession')); + }); + + test('returns 1 when the name argument is missing', () async { + final cmd = MakeListenerCommand(testRoot: projectRoot.path); + final code = await cmd.handle(buildCommandContext(cmd, [])); + expect(code, 1); + }); + + test('returns 1 when the file already exists without --force', () async { + final cmd = MakeListenerCommand(testRoot: projectRoot.path); + await cmd.handle(buildCommandContext(cmd, ['AuthRestore'])); + + final second = MakeListenerCommand(testRoot: projectRoot.path); + final code = await second.handle( + buildCommandContext(second, ['AuthRestore']), + ); + expect(code, 1); + }); + }); +} diff --git a/test/cli/commands/make_middleware_command_test.dart b/test/cli/commands/make_middleware_command_test.dart new file mode 100644 index 00000000..107eb681 --- /dev/null +++ b/test/cli/commands/make_middleware_command_test.dart @@ -0,0 +1,107 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttersdk_artisan/artisan.dart'; +import 'package:magic/src/cli/commands/make_middleware_command.dart'; +import 'package:path/path.dart' as p; + +import '_harness.dart'; + +void main() { + group('MakeMiddlewareCommand metadata', () { + final cmd = MakeMiddlewareCommand(); + + test('declares name make:middleware', () { + expect(cmd.name, 'make:middleware'); + }); + + test('extends ArtisanGeneratorCommand', () { + expect(cmd, isA()); + }); + + test('declares CommandBoot.none', () { + expect(cmd.boot, CommandBoot.none); + }); + }); + + group('MakeMiddlewareCommand.handle()', () { + late Directory projectRoot; + + setUp(() { + projectRoot = Directory.systemTemp.createTempSync('make_middleware_'); + }); + + tearDown(() { + if (projectRoot.existsSync()) projectRoot.deleteSync(recursive: true); + }); + + test( + 'scaffolds a middleware extending MagicMiddleware with handle(next)', + () async { + final cmd = MakeMiddlewareCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['EnsureAuthenticated']), + ); + + expect(code, 0); + final file = File( + p.join( + projectRoot.path, + 'lib', + 'app', + 'middleware', + 'ensure_authenticated.dart', + ), + ); + expect(file.existsSync(), isTrue); + + final content = file.readAsStringSync(); + expect( + content, + contains('class EnsureAuthenticated extends MagicMiddleware'), + ); + expect(content, contains('Future handle(void Function() next)')); + }, + ); + + test('supports a nested name', () async { + final cmd = MakeMiddlewareCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['Admin/RoleCheck']), + ); + + expect(code, 0); + final file = File( + p.join( + projectRoot.path, + 'lib', + 'app', + 'middleware', + 'admin', + 'role_check.dart', + ), + ); + expect(file.existsSync(), isTrue); + expect(file.readAsStringSync(), contains('class RoleCheck')); + }); + + test('returns 1 when the name argument is missing', () async { + final cmd = MakeMiddlewareCommand(testRoot: projectRoot.path); + final code = await cmd.handle(buildCommandContext(cmd, [])); + expect(code, 1); + }); + + test('returns 1 when the file already exists without --force', () async { + final cmd = MakeMiddlewareCommand(testRoot: projectRoot.path); + await cmd.handle( + buildCommandContext(cmd, ['EnsureAuthenticated']), + ); + + final second = MakeMiddlewareCommand(testRoot: projectRoot.path); + final code = await second.handle( + buildCommandContext(second, ['EnsureAuthenticated']), + ); + expect(code, 1); + }); + }); +} diff --git a/test/cli/commands/make_migration_command_test.dart b/test/cli/commands/make_migration_command_test.dart new file mode 100644 index 00000000..b9ebc986 --- /dev/null +++ b/test/cli/commands/make_migration_command_test.dart @@ -0,0 +1,140 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttersdk_artisan/artisan.dart'; +import 'package:magic/src/cli/commands/make_migration_command.dart'; +import 'package:path/path.dart' as p; + +import '_harness.dart'; + +/// Migration filenames carry a `m_YYYYMMDDHHmmss_` timestamp prefix. Match +/// the shape with a regex, never a fixed timestamp (the QA requirement). +final RegExp _migrationFileNamePattern = RegExp(r'^m_\d{14}_[a-z0-9_]+\.dart$'); + +void main() { + group('MakeMigrationCommand metadata', () { + final cmd = MakeMigrationCommand(); + + test('declares name make:migration', () { + expect(cmd.name, 'make:migration'); + }); + + test('extends ArtisanGeneratorCommand', () { + expect(cmd, isA()); + }); + + test('declares CommandBoot.none', () { + expect(cmd.boot, CommandBoot.none); + }); + }); + + group('MakeMigrationCommand.handle()', () { + late Directory projectRoot; + late Directory migrationsDir; + + setUp(() { + projectRoot = Directory.systemTemp.createTempSync('make_migration_'); + migrationsDir = Directory( + p.join(projectRoot.path, 'lib', 'database', 'migrations'), + ); + }); + + tearDown(() { + if (projectRoot.existsSync()) projectRoot.deleteSync(recursive: true); + }); + + /// Returns the single migration file written under [migrationsDir]. + File writtenFile() { + final files = migrationsDir + .listSync() + .whereType() + .where((f) => p.basename(f.path).endsWith('.dart')) + .toList(); + expect(files, hasLength(1)); + return files.single; + } + + test( + 'writes a timestamped file matching the migration name regex', + () async { + final cmd = MakeMigrationCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['create_users_table']), + ); + + expect(code, 0); + final file = writtenFile(); + expect(p.basename(file.path), matches(_migrationFileNamePattern)); + expect(p.basename(file.path), contains('create_users_table')); + }, + ); + + test('--create writes a synchronous Schema.create up()/down()', () async { + final cmd = MakeMigrationCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, [ + 'create_monitors_table', + '--create=monitors', + ]), + ); + + expect(code, 0); + final content = writtenFile().readAsStringSync(); + expect(content, contains('void up() {')); + expect(content, contains('void down() {')); + expect( + content, + contains("Schema.create('monitors', (Blueprint table) {"), + ); + expect(content, contains("Schema.dropIfExists('monitors');")); + expect(content, isNot(contains('Future'))); + }); + + test('--table writes a synchronous plain migration', () async { + final cmd = MakeMigrationCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, [ + 'add_region_to_monitors', + '--table=monitors', + ]), + ); + + expect(code, 0); + final content = writtenFile().readAsStringSync(); + expect(content, contains('void up() {')); + expect(content, contains('void down() {')); + expect(content, isNot(contains('Schema.create('))); + expect(content, isNot(contains('Future'))); + }); + + test('resolves a nested name to a nested directory', () async { + final cmd = MakeMigrationCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['Admin/create_reports_table']), + ); + + expect(code, 0); + final nestedDir = Directory(p.join(migrationsDir.path, 'admin')); + final files = nestedDir.listSync().whereType().toList(); + expect(files, hasLength(1)); + expect(p.basename(files.single.path), matches(_migrationFileNamePattern)); + }); + + test('returns 1 when the name argument is missing', () async { + final cmd = MakeMigrationCommand(testRoot: projectRoot.path); + final code = await cmd.handle(buildCommandContext(cmd, [])); + expect(code, 1); + }); + + test('returns 1 when the file already exists without --force', () async { + final cmd = MakeMigrationCommand(testRoot: projectRoot.path); + await cmd.handle( + buildCommandContext(cmd, ['create_users_table']), + ); + final code = await cmd.handle( + buildCommandContext(cmd, ['create_users_table']), + ); + expect(code, 1); + }); + }); +} diff --git a/test/cli/commands/make_model_command_test.dart b/test/cli/commands/make_model_command_test.dart new file mode 100644 index 00000000..623f6fc3 --- /dev/null +++ b/test/cli/commands/make_model_command_test.dart @@ -0,0 +1,176 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttersdk_artisan/artisan.dart'; +import 'package:magic/src/cli/commands/make_model_command.dart'; +import 'package:path/path.dart' as p; + +import '_harness.dart'; + +void main() { + group('MakeModelCommand metadata', () { + final cmd = MakeModelCommand(); + + test('declares name make:model', () { + expect(cmd.name, 'make:model'); + }); + + test('extends ArtisanGeneratorCommand', () { + expect(cmd, isA()); + }); + + test('declares CommandBoot.none', () { + expect(cmd.boot, CommandBoot.none); + }); + }); + + group('MakeModelCommand.handle()', () { + late Directory projectRoot; + + setUp(() { + projectRoot = Directory.systemTemp.createTempSync('make_model_'); + }); + + tearDown(() { + if (projectRoot.existsSync()) projectRoot.deleteSync(recursive: true); + }); + + test( + 'scaffolds table/resource/fillable/casts/fromMap at the default path', + () async { + final cmd = MakeModelCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['Monitor']), + ); + + expect(code, 0); + final file = File( + p.join(projectRoot.path, 'lib', 'app', 'models', 'monitor.dart'), + ); + expect(file.existsSync(), isTrue); + final content = file.readAsStringSync(); + expect(content, contains("String get table => 'monitors';")); + expect(content, contains("String get resource => 'monitors';")); + expect(content, contains('List get fillable => [];')); + expect(content, contains('Map get casts => {};')); + expect(content, contains('static Monitor fromMap(')); + }, + ); + + test('resolves a nested name to a nested path', () async { + final cmd = MakeModelCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['Admin/Monitor']), + ); + + expect(code, 0); + final file = File( + p.join( + projectRoot.path, + 'lib', + 'app', + 'models', + 'admin', + 'monitor.dart', + ), + ); + expect(file.existsSync(), isTrue); + expect(file.readAsStringSync(), contains('static Monitor fromMap(')); + }); + + test('returns 1 when the name argument is missing', () async { + final cmd = MakeModelCommand(testRoot: projectRoot.path); + final code = await cmd.handle(buildCommandContext(cmd, [])); + expect(code, 1); + }); + + test('returns 1 when the file already exists without --force', () async { + final cmd = MakeModelCommand(testRoot: projectRoot.path); + await cmd.handle(buildCommandContext(cmd, ['Monitor'])); + final code = await cmd.handle( + buildCommandContext(cmd, ['Monitor']), + ); + expect(code, 1); + }); + + test( + '-a fans out to model, migration, factory, seeder, policy, repository and controller', + () async { + final cmd = MakeModelCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['Monitor', '-a']), + ); + + expect(code, 0); + + final modelFile = File( + p.join(projectRoot.path, 'lib', 'app', 'models', 'monitor.dart'), + ); + expect(modelFile.existsSync(), isTrue); + + final migrationsDir = Directory( + p.join(projectRoot.path, 'lib', 'database', 'migrations'), + ); + final migrationFiles = migrationsDir.existsSync() + ? migrationsDir.listSync().whereType().toList() + : []; + expect(migrationFiles, hasLength(1)); + + final factoryFile = File( + p.join( + projectRoot.path, + 'lib', + 'database', + 'factories', + 'monitor_factory.dart', + ), + ); + expect(factoryFile.existsSync(), isTrue); + + final seederFile = File( + p.join( + projectRoot.path, + 'lib', + 'database', + 'seeders', + 'monitor_seeder.dart', + ), + ); + expect(seederFile.existsSync(), isTrue); + + final policyFile = File( + p.join( + projectRoot.path, + 'lib', + 'app', + 'policies', + 'monitor_policy.dart', + ), + ); + expect(policyFile.existsSync(), isTrue); + + final repositoryFile = File( + p.join( + projectRoot.path, + 'lib', + 'app', + 'repositories', + 'monitor_repository.dart', + ), + ); + expect(repositoryFile.existsSync(), isTrue); + + final controllerFile = File( + p.join( + projectRoot.path, + 'lib', + 'app', + 'controllers', + 'monitor_controller.dart', + ), + ); + expect(controllerFile.existsSync(), isTrue); + }, + ); + }); +} diff --git a/test/cli/commands/make_policy_command_test.dart b/test/cli/commands/make_policy_command_test.dart new file mode 100644 index 00000000..b052dfb5 --- /dev/null +++ b/test/cli/commands/make_policy_command_test.dart @@ -0,0 +1,99 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttersdk_artisan/artisan.dart'; +import 'package:magic/src/cli/commands/make_policy_command.dart'; +import 'package:path/path.dart' as p; + +import '_harness.dart'; + +void main() { + group('MakePolicyCommand metadata', () { + final cmd = MakePolicyCommand(); + + test('declares name make:policy', () { + expect(cmd.name, 'make:policy'); + }); + + test('extends ArtisanGeneratorCommand', () { + expect(cmd, isA()); + }); + + test('declares CommandBoot.none', () { + expect(cmd.boot, CommandBoot.none); + }); + }); + + group('MakePolicyCommand.handle()', () { + late Directory projectRoot; + + setUp(() { + projectRoot = Directory.systemTemp.createTempSync('make_policy_'); + }); + + tearDown(() { + if (projectRoot.existsSync()) projectRoot.deleteSync(recursive: true); + }); + + test('scaffolds a policy extending Policy with --model', () async { + final cmd = MakePolicyCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['Monitor', '--model=Monitor']), + ); + + expect(code, 0); + final file = File( + p.join( + projectRoot.path, + 'lib', + 'app', + 'policies', + 'monitor_policy.dart', + ), + ); + expect(file.existsSync(), isTrue); + + final content = file.readAsStringSync(); + expect(content, contains('class MonitorPolicy extends Policy')); + expect(content, contains("Gate.define('view-monitor', _view)")); + }); + + test('supports a nested name', () async { + final cmd = MakePolicyCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['Admin/Dashboard']), + ); + + expect(code, 0); + final file = File( + p.join( + projectRoot.path, + 'lib', + 'app', + 'policies', + 'admin', + 'dashboard_policy.dart', + ), + ); + expect(file.existsSync(), isTrue); + expect(file.readAsStringSync(), contains('class DashboardPolicy')); + }); + + test('returns 1 when the name argument is missing', () async { + final cmd = MakePolicyCommand(testRoot: projectRoot.path); + final code = await cmd.handle(buildCommandContext(cmd, [])); + expect(code, 1); + }); + + test('returns 1 when the file already exists without --force', () async { + final cmd = MakePolicyCommand(testRoot: projectRoot.path); + await cmd.handle(buildCommandContext(cmd, ['Monitor'])); + + final second = MakePolicyCommand(testRoot: projectRoot.path); + final code = await second.handle( + buildCommandContext(second, ['Monitor']), + ); + expect(code, 1); + }); + }); +} diff --git a/test/cli/commands/make_provider_command_test.dart b/test/cli/commands/make_provider_command_test.dart new file mode 100644 index 00000000..767b6158 --- /dev/null +++ b/test/cli/commands/make_provider_command_test.dart @@ -0,0 +1,132 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttersdk_artisan/artisan.dart'; +import 'package:magic/src/cli/commands/make_provider_command.dart'; +import 'package:path/path.dart' as p; + +import '_harness.dart'; + +void main() { + group('MakeProviderCommand metadata', () { + final cmd = MakeProviderCommand(); + + test('declares name make:provider', () { + expect(cmd.name, 'make:provider'); + }); + + test('extends ArtisanGeneratorCommand', () { + expect(cmd, isA()); + }); + + test('declares CommandBoot.none', () { + expect(cmd.boot, CommandBoot.none); + }); + }); + + group('MakeProviderCommand.handle()', () { + late Directory projectRoot; + + setUp(() { + projectRoot = Directory.systemTemp.createTempSync('make_provider_'); + }); + + tearDown(() { + if (projectRoot.existsSync()) projectRoot.deleteSync(recursive: true); + }); + + test( + 'scaffolds a provider extending ServiceProvider with the auto-suffix', + () async { + final cmd = MakeProviderCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['App']), + ); + + expect(code, 0); + final file = File( + p.join( + projectRoot.path, + 'lib', + 'app', + 'providers', + 'app_service_provider.dart', + ), + ); + expect(file.existsSync(), isTrue); + + final content = file.readAsStringSync(); + expect( + content, + contains('class AppServiceProvider extends ServiceProvider'), + ); + expect(content, contains('void register()')); + expect(content, contains('Future boot()')); + }, + ); + + test( + 'does not double-suffix when ServiceProvider is already present', + () async { + final cmd = MakeProviderCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['AppServiceProvider']), + ); + + expect(code, 0); + final file = File( + p.join( + projectRoot.path, + 'lib', + 'app', + 'providers', + 'app_service_provider.dart', + ), + ); + expect(file.existsSync(), isTrue); + expect( + file.readAsStringSync(), + contains('class AppServiceProvider extends ServiceProvider'), + ); + }, + ); + + test('supports a nested name', () async { + final cmd = MakeProviderCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['Billing/Stripe']), + ); + + expect(code, 0); + final file = File( + p.join( + projectRoot.path, + 'lib', + 'app', + 'providers', + 'billing', + 'stripe_service_provider.dart', + ), + ); + expect(file.existsSync(), isTrue); + expect(file.readAsStringSync(), contains('class StripeServiceProvider')); + }); + + test('returns 1 when the name argument is missing', () async { + final cmd = MakeProviderCommand(testRoot: projectRoot.path); + final code = await cmd.handle(buildCommandContext(cmd, [])); + expect(code, 1); + }); + + test('returns 1 when the file already exists without --force', () async { + final cmd = MakeProviderCommand(testRoot: projectRoot.path); + await cmd.handle(buildCommandContext(cmd, ['App'])); + + final second = MakeProviderCommand(testRoot: projectRoot.path); + final code = await second.handle( + buildCommandContext(second, ['App']), + ); + expect(code, 1); + }); + }); +} diff --git a/test/cli/commands/make_repository_command_test.dart b/test/cli/commands/make_repository_command_test.dart index 27564056..600beff2 100644 --- a/test/cli/commands/make_repository_command_test.dart +++ b/test/cli/commands/make_repository_command_test.dart @@ -5,13 +5,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:magic/src/cli/commands/make_repository_command.dart'; import 'package:path/path.dart' as p; -/// A bare [ArtisanContext] driving the command with [args]. -ArtisanContext _ctx(MakeRepositoryCommand cmd, List args) { - final parser = ArgParser(); - cmd.configure(parser); - final input = ArgvInput.parse(parser, args); - return ArtisanContext.bare(input, BufferedOutput()); -} +import '_harness.dart'; void main() { group('MakeRepositoryCommand metadata', () { @@ -43,7 +37,9 @@ void main() { test('scaffolds a repository extending Repository', () async { final cmd = MakeRepositoryCommand(testRoot: projectRoot.path); - final code = await cmd.handle(_ctx(cmd, ['Monitor'])); + final code = await cmd.handle( + buildCommandContext(cmd, ['Monitor']), + ); expect(code, 0); final file = File( @@ -76,7 +72,7 @@ void main() { test('does not double-suffix when Repository is already present', () async { final cmd = MakeRepositoryCommand(testRoot: projectRoot.path); - await cmd.handle(_ctx(cmd, ['MonitorRepository'])); + await cmd.handle(buildCommandContext(cmd, ['MonitorRepository'])); final file = File( p.join( @@ -92,8 +88,34 @@ void main() { test('returns 1 when the name argument is missing', () async { final cmd = MakeRepositoryCommand(testRoot: projectRoot.path); - final code = await cmd.handle(_ctx(cmd, [])); + final code = await cmd.handle(buildCommandContext(cmd, [])); expect(code, 1); }); + + test('--test chains make:test --kind=repository', () async { + final cmd = MakeRepositoryCommand(testRoot: projectRoot.path); + File( + p.join(projectRoot.path, 'pubspec.yaml'), + ).createSync(recursive: true); + File( + p.join(projectRoot.path, 'pubspec.yaml'), + ).writeAsStringSync('name: fixture_app\n'); + + final code = await cmd.handle( + buildCommandContext(cmd, ['Monitor', '--test']), + ); + + expect(code, 0); + final testFile = File( + p.join( + projectRoot.path, + 'test', + 'app', + 'repositories', + 'monitor_repository_test.dart', + ), + ); + expect(testFile.existsSync(), isTrue); + }); }); } diff --git a/test/cli/commands/make_request_command_test.dart b/test/cli/commands/make_request_command_test.dart new file mode 100644 index 00000000..6783e9b5 --- /dev/null +++ b/test/cli/commands/make_request_command_test.dart @@ -0,0 +1,125 @@ +import 'dart:io'; + +import 'package:fluttersdk_artisan/artisan.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/src/cli/commands/make_request_command.dart'; +import 'package:path/path.dart' as p; + +import '_harness.dart'; + +void main() { + group('MakeRequestCommand metadata', () { + final cmd = MakeRequestCommand(); + + test('declares name make:request', () { + expect(cmd.name, 'make:request'); + }); + + test('extends ArtisanGeneratorCommand', () { + expect(cmd, isA()); + }); + + test('declares CommandBoot.none', () { + expect(cmd.boot, CommandBoot.none); + }); + }); + + group('MakeRequestCommand.handle()', () { + late Directory projectRoot; + + setUp(() { + projectRoot = Directory.systemTemp.createTempSync('make_request_'); + File( + p.join(projectRoot.path, 'pubspec.yaml'), + ).writeAsStringSync('name: fixture_app\n'); + }); + + tearDown(() { + if (projectRoot.existsSync()) projectRoot.deleteSync(recursive: true); + }); + + test('scaffolds a FormRequest subclass with a const constructor', () async { + final cmd = MakeRequestCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['StoreMonitor']), + ); + + expect(code, 0); + final file = File( + p.join( + projectRoot.path, + 'lib', + 'app', + 'validation', + 'requests', + 'store_monitor_request.dart', + ), + ); + expect(file.existsSync(), isTrue); + + final content = file.readAsStringSync(); + expect( + content, + contains('class StoreMonitorRequest extends FormRequest'), + ); + expect(content, contains('const StoreMonitorRequest();')); + expect(content, contains('Map> rules()')); + }); + + test('does not double-suffix when Request is already present', () async { + final cmd = MakeRequestCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['StoreMonitorRequest']), + ); + + expect(code, 0); + final file = File( + p.join( + projectRoot.path, + 'lib', + 'app', + 'validation', + 'requests', + 'store_monitor_request.dart', + ), + ); + expect(file.existsSync(), isTrue); + expect( + file.readAsStringSync(), + contains('class StoreMonitorRequest extends FormRequest'), + ); + }); + + test('returns 1 when the name argument is missing', () async { + final cmd = MakeRequestCommand(testRoot: projectRoot.path); + final code = await cmd.handle(buildCommandContext(cmd, [])); + expect(code, 1); + }); + + test('--test chains make:test --kind=request', () async { + final cmd = MakeRequestCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['StoreMonitor', '--test']), + ); + + expect(code, 0); + final testFile = File( + p.join( + projectRoot.path, + 'test', + 'app', + 'validation', + 'requests', + 'store_monitor_request_test.dart', + ), + ); + expect(testFile.existsSync(), isTrue); + expect( + testFile.readAsStringSync(), + contains( + "import 'package:fixture_app/app/validation/requests/store_monitor_request.dart';", + ), + ); + }); + }); +} diff --git a/test/cli/commands/make_resource_command_test.dart b/test/cli/commands/make_resource_command_test.dart new file mode 100644 index 00000000..83d1997a --- /dev/null +++ b/test/cli/commands/make_resource_command_test.dart @@ -0,0 +1,252 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttersdk_artisan/artisan.dart'; +import 'package:magic/src/cli/commands/make_resource_command.dart'; +import 'package:magic/src/cli/magic_artisan_provider.dart'; +import 'package:path/path.dart' as p; + +import '_harness.dart'; + +/// Every file `make:resource Monitor` writes outside the views, relative to +/// the project root. +const List _coreFiles = [ + 'lib/app/models/monitor.dart', + 'lib/database/factories/monitor_factory.dart', + 'lib/app/repositories/monitor_repository.dart', + 'lib/app/actions/monitors/create_monitor.dart', + 'lib/app/actions/monitors/update_monitor.dart', + 'lib/app/actions/monitors/delete_monitor.dart', + 'lib/app/validation/requests/store_monitor_request.dart', + 'lib/app/validation/requests/update_monitor_request.dart', + 'lib/app/forms/monitor_form_object.dart', + 'lib/app/controllers/monitor_controller.dart', + 'test/app/actions/monitors/create_monitor_test.dart', + 'test/app/actions/monitors/update_monitor_test.dart', + 'test/app/actions/monitors/delete_monitor_test.dart', + 'test/app/forms/monitor_form_object_test.dart', + 'test/app/controllers/monitor_controller_test.dart', +]; + +/// The two views `make:resource Monitor` writes unless `--no-views`. +const List _viewFiles = [ + 'lib/resources/views/monitors/monitors_list_view.dart', + 'lib/resources/views/monitors/monitor_form_view.dart', +]; + +void main() { + group('MakeResourceCommand metadata', () { + final cmd = MakeResourceCommand(); + + test('declares name make:resource', () { + expect(cmd.name, 'make:resource'); + }); + + test('declares CommandBoot.none', () { + expect(cmd.boot, CommandBoot.none); + }); + + test('is registered by MagicArtisanProvider', () { + final names = MagicArtisanProvider().commands().map((c) => c.name); + expect(names, contains('make:resource')); + }); + }); + + group('MakeResourceCommand.handle()', () { + late Directory projectRoot; + + File file(String relative) => File(p.join(projectRoot.path, relative)); + + Future<(int, String)> run(List args) async { + final cmd = MakeResourceCommand(testRoot: projectRoot.path); + final ctx = buildCommandContext(cmd, args); + final code = await cmd.handle(ctx); + return (code, (ctx.output as BufferedOutput).content); + } + + setUp(() { + projectRoot = Directory.systemTemp.createTempSync('make_resource_'); + file('pubspec.yaml').writeAsStringSync('name: app\n'); + }); + + tearDown(() { + if (projectRoot.existsSync()) projectRoot.deleteSync(recursive: true); + }); + + test('writes every file of the vertical at its path', () async { + final (code, _) = await run(['Monitor']); + + expect(code, 0); + for (final relative in [..._coreFiles, ..._viewFiles]) { + expect(file(relative).existsSync(), isTrue, reason: relative); + } + + expect( + file('lib/app/actions/monitors/create_monitor.dart').readAsStringSync(), + contains('class CreateMonitor'), + ); + expect( + file('lib/app/forms/monitor_form_object.dart').readAsStringSync(), + contains('StoreMonitorRequest'), + ); + expect( + file('lib/app/controllers/monitor_controller.dart').readAsStringSync(), + allOf(contains('RunsActions'), contains('MonitorRepository')), + ); + expect( + file(_viewFiles.first).readAsStringSync(), + contains('RefetchesOnMount'), + ); + expect( + file(_viewFiles.last).readAsStringSync(), + contains('late final form = MonitorFormObject();'), + ); + }); + + test('prints the route lines and never touches a routes file', () async { + final (code, output) = await run(['Monitor']); + + expect(code, 0); + expect( + output, + contains( + "MagicRoute.page('/monitors', () => const MonitorsListView())" + ".name('monitors.index');", + ), + ); + expect( + output, + contains( + "MagicRoute.page('/monitors/create', () => const MonitorFormView())" + ".name('monitors.create').stacked();", + ), + ); + expect( + Directory(p.join(projectRoot.path, 'lib', 'routes')).existsSync(), + isFalse, + ); + }); + + test('--no-views omits the views and the route lines', () async { + final (code, output) = await run(['Monitor', '--no-views']); + + expect(code, 0); + for (final relative in _coreFiles) { + expect(file(relative).existsSync(), isTrue, reason: relative); + } + for (final relative in _viewFiles) { + expect(file(relative).existsSync(), isFalse, reason: relative); + } + expect(output, isNot(contains('MagicRoute.page'))); + }); + + test('--no-model omits the model and its factory', () async { + final (code, _) = await run(['Monitor', '--no-model']); + + expect(code, 0); + expect(file('lib/app/models/monitor.dart').existsSync(), isFalse); + expect( + file('lib/database/factories/monitor_factory.dart').existsSync(), + isFalse, + ); + expect( + file('lib/app/repositories/monitor_repository.dart').existsSync(), + isTrue, + ); + }); + + test('skips an existing model with a note and writes the rest', () async { + const existing = '// hand-written model\n'; + file('lib/app/models/monitor.dart') + ..createSync(recursive: true) + ..writeAsStringSync(existing); + + final (code, output) = await run(['Monitor']); + + expect(code, 0); + expect(file('lib/app/models/monitor.dart').readAsStringSync(), existing); + expect(output, contains('Skipped')); + expect(output, contains(p.join('lib', 'app', 'models', 'monitor.dart'))); + for (final relative in _coreFiles.skip(1)) { + expect(file(relative).existsSync(), isTrue, reason: relative); + } + }); + + test('a second run without --force exits 1 and changes no file', () async { + final (first, _) = await run(['Monitor']); + expect(first, 0); + + // Backdate every written file so any rewrite shows up as a new mtime. + final backdated = DateTime(2000); + final written = [..._coreFiles, ..._viewFiles]; + for (final relative in written) { + file(relative).setLastModifiedSync(backdated); + } + + final (second, output) = await run(['Monitor']); + + expect(second, 1); + expect( + output, + contains( + p.join('lib', 'app', 'repositories', 'monitor_repository.dart'), + ), + ); + for (final relative in written) { + expect(file(relative).lastModifiedSync(), backdated, reason: relative); + } + }); + + test('a clash on one file writes nothing at all', () async { + file('lib/app/forms/monitor_form_object.dart') + ..createSync(recursive: true) + ..writeAsStringSync('// existing form\n'); + + final (code, output) = await run(['Monitor']); + + expect(code, 1); + expect( + output, + contains(p.join('lib', 'app', 'forms', 'monitor_form_object.dart')), + ); + expect(file('lib/app/models/monitor.dart').existsSync(), isFalse); + expect( + file('lib/app/repositories/monitor_repository.dart').existsSync(), + isFalse, + ); + }); + + test('--force overwrites the clashes, tests included', () async { + await run(['Monitor']); + file( + 'test/app/forms/monitor_form_object_test.dart', + ).writeAsStringSync('// stale\n'); + + final (code, _) = await run(['Monitor', '--force']); + + expect(code, 0); + expect( + file('test/app/forms/monitor_form_object_test.dart').readAsStringSync(), + contains('MonitorFormObject'), + ); + }); + + test('leaves an unrelated file byte-identical', () async { + final unrelated = file('lib/app/services/billing.dart') + ..createSync(recursive: true) + ..writeAsBytesSync([0, 1, 2, 255, 10]); + + final (code, _) = await run(['Monitor']); + + expect(code, 0); + expect(unrelated.readAsBytesSync(), [0, 1, 2, 255, 10]); + }); + + test('exits 1 without a name', () async { + final (code, output) = await run([]); + + expect(code, 1); + expect(output, contains('Not enough arguments')); + }); + }); +} diff --git a/test/cli/commands/make_seeder_command_test.dart b/test/cli/commands/make_seeder_command_test.dart new file mode 100644 index 00000000..5141cb55 --- /dev/null +++ b/test/cli/commands/make_seeder_command_test.dart @@ -0,0 +1,100 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttersdk_artisan/artisan.dart'; +import 'package:magic/src/cli/commands/make_seeder_command.dart'; +import 'package:path/path.dart' as p; + +import '_harness.dart'; + +void main() { + group('MakeSeederCommand metadata', () { + final cmd = MakeSeederCommand(); + + test('declares name make:seeder', () { + expect(cmd.name, 'make:seeder'); + }); + + test('extends ArtisanGeneratorCommand', () { + expect(cmd, isA()); + }); + + test('declares CommandBoot.none', () { + expect(cmd.boot, CommandBoot.none); + }); + }); + + group('MakeSeederCommand.handle()', () { + late Directory projectRoot; + + setUp(() { + projectRoot = Directory.systemTemp.createTempSync('make_seeder_'); + }); + + tearDown(() { + if (projectRoot.existsSync()) projectRoot.deleteSync(recursive: true); + }); + + test('scaffolds at the default path with the key class line', () async { + final cmd = MakeSeederCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['Monitor']), + ); + + expect(code, 0); + final file = File( + p.join( + projectRoot.path, + 'lib', + 'database', + 'seeders', + 'monitor_seeder.dart', + ), + ); + expect(file.existsSync(), isTrue); + expect( + file.readAsStringSync(), + contains('class MonitorSeeder extends Seeder'), + ); + }); + + test('resolves a nested name to a nested path', () async { + final cmd = MakeSeederCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['Admin/Monitor']), + ); + + expect(code, 0); + final file = File( + p.join( + projectRoot.path, + 'lib', + 'database', + 'seeders', + 'admin', + 'monitor_seeder.dart', + ), + ); + expect(file.existsSync(), isTrue); + expect( + file.readAsStringSync(), + contains('class MonitorSeeder extends Seeder'), + ); + }); + + test('returns 1 when the name argument is missing', () async { + final cmd = MakeSeederCommand(testRoot: projectRoot.path); + final code = await cmd.handle(buildCommandContext(cmd, [])); + expect(code, 1); + }); + + test('returns 1 when the file already exists without --force', () async { + final cmd = MakeSeederCommand(testRoot: projectRoot.path); + await cmd.handle(buildCommandContext(cmd, ['Monitor'])); + final code = await cmd.handle( + buildCommandContext(cmd, ['Monitor']), + ); + expect(code, 1); + }); + }); +} diff --git a/test/cli/commands/make_test_command_test.dart b/test/cli/commands/make_test_command_test.dart new file mode 100644 index 00000000..69195bd9 --- /dev/null +++ b/test/cli/commands/make_test_command_test.dart @@ -0,0 +1,311 @@ +import 'dart:io'; + +import 'package:fluttersdk_artisan/artisan.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/src/cli/commands/make_test_command.dart'; +import 'package:path/path.dart' as p; + +import '_harness.dart'; + +void main() { + group('MakeTestCommand metadata', () { + final cmd = MakeTestCommand(); + + test('declares name make:test', () { + expect(cmd.name, 'make:test'); + }); + + test('extends ArtisanGeneratorCommand', () { + expect(cmd, isA()); + }); + + test('declares CommandBoot.none', () { + expect(cmd.boot, CommandBoot.none); + }); + }); + + group('MakeTestCommand.handle()', () { + late Directory projectRoot; + + setUp(() { + projectRoot = Directory.systemTemp.createTempSync('make_test_'); + File( + p.join(projectRoot.path, 'pubspec.yaml'), + ).writeAsStringSync('name: fixture_app\n'); + }); + + tearDown(() { + if (projectRoot.existsSync()) projectRoot.deleteSync(recursive: true); + }); + + test('scaffolds a controller test mirroring the controller path', () async { + final cmd = MakeTestCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['Monitor', '--kind=controller']), + ); + + expect(code, 0); + final file = File( + p.join( + projectRoot.path, + 'test', + 'app', + 'controllers', + 'monitor_controller_test.dart', + ), + ); + expect(file.existsSync(), isTrue); + + final content = file.readAsStringSync(); + expect( + content, + contains( + "import 'package:fixture_app/app/controllers/monitor_controller.dart';", + ), + ); + expect(content, contains('MagicAction.flush()')); + }); + + test('scaffolds an action test mirroring a nested action path', () async { + final cmd = MakeTestCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, [ + 'Monitors/PauseMonitor', + '--kind=action', + ]), + ); + + expect(code, 0); + final file = File( + p.join( + projectRoot.path, + 'test', + 'app', + 'actions', + 'monitors', + 'pause_monitor_test.dart', + ), + ); + expect(file.existsSync(), isTrue); + + final content = file.readAsStringSync(); + expect( + content, + contains( + "import 'package:fixture_app/app/actions/monitors/pause_monitor.dart';", + ), + ); + expect(content, contains('MagicAction.flush()')); + }); + + test('scaffolds a form test mirroring the FormObject path', () async { + final cmd = MakeTestCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['Monitor', '--kind=form']), + ); + + expect(code, 0); + final file = File( + p.join( + projectRoot.path, + 'test', + 'app', + 'forms', + 'monitor_form_object_test.dart', + ), + ); + expect(file.existsSync(), isTrue); + + final content = file.readAsStringSync(); + expect( + content, + contains( + "import 'package:fixture_app/app/forms/monitor_form_object.dart';", + ), + ); + expect(content, contains('MagicAction.flush()')); + }); + + test('scaffolds a repository test mirroring the Repository path', () async { + final cmd = MakeTestCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['Monitor', '--kind=repository']), + ); + + expect(code, 0); + final file = File( + p.join( + projectRoot.path, + 'test', + 'app', + 'repositories', + 'monitor_repository_test.dart', + ), + ); + expect(file.existsSync(), isTrue); + + final content = file.readAsStringSync(); + expect( + content, + contains( + "import 'package:fixture_app/app/repositories/monitor_repository.dart';", + ), + ); + expect(content, contains('resetForSession')); + }); + + test('scaffolds a request test mirroring the Request path', () async { + final cmd = MakeTestCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['StoreMonitor', '--kind=request']), + ); + + expect(code, 0); + final file = File( + p.join( + projectRoot.path, + 'test', + 'app', + 'validation', + 'requests', + 'store_monitor_request_test.dart', + ), + ); + expect(file.existsSync(), isTrue); + + final content = file.readAsStringSync(); + expect( + content, + contains( + "import 'package:fixture_app/app/validation/requests/store_monitor_request.dart';", + ), + ); + expect(content, contains('.rules()')); + }); + + test('scaffolds a view test mirroring the View path', () async { + final cmd = MakeTestCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['Login', '--kind=view']), + ); + + expect(code, 0); + final file = File( + p.join( + projectRoot.path, + 'test', + 'resources', + 'views', + 'login_view_test.dart', + ), + ); + expect(file.existsSync(), isTrue); + + final content = file.readAsStringSync(); + expect( + content, + contains( + "import 'package:fixture_app/resources/views/login_view.dart';", + ), + ); + expect(content, contains('WindTheme(')); + }); + + test('scaffolds a unit test under test/unit', () async { + final cmd = MakeTestCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['StatusFormatter', '--kind=unit']), + ); + + expect(code, 0); + final file = File( + p.join(projectRoot.path, 'test', 'unit', 'status_formatter_test.dart'), + ); + expect(file.existsSync(), isTrue); + + final content = file.readAsStringSync(); + expect(content, contains('package:fixture_app/')); + expect(content, contains('// TODO: implement test')); + }); + + test('returns 1 when the name argument is missing', () async { + final cmd = MakeTestCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['--kind=action']), + ); + expect(code, 1); + }); + + test('returns 1 when pubspec.yaml carries no name', () async { + File( + p.join(projectRoot.path, 'pubspec.yaml'), + ).writeAsStringSync('description: nameless\n'); + + final cmd = MakeTestCommand(testRoot: projectRoot.path); + final parser = ArgParser(); + cmd.configure(parser); + final input = ArgvInput.parse(parser, [ + 'Monitor', + '--kind=controller', + ]); + final output = BufferedOutput(); + final code = await cmd.handle(ArtisanContext.bare(input, output)); + + expect(code, 1); + expect(output.content, contains('name')); + }); + + test('returns 1 and lists the kinds when --kind is unknown', () async { + final cmd = MakeTestCommand(testRoot: projectRoot.path); + final parser = ArgParser(); + cmd.configure(parser); + final input = ArgvInput.parse(parser, [ + 'Monitor', + '--kind=bogus', + ]); + final output = BufferedOutput(); + final code = await cmd.handle(ArtisanContext.bare(input, output)); + + expect(code, 1); + expect(output.content, contains('controller')); + expect(output.content, contains('unit')); + }); + + test( + 'returns 1 naming the missing pubspec.yaml when the project has none', + () async { + final bareRoot = Directory.systemTemp.createTempSync('make_test_bare_'); + addTearDown(() => bareRoot.deleteSync(recursive: true)); + + final cmd = MakeTestCommand(testRoot: bareRoot.path); + final parser = ArgParser(); + cmd.configure(parser); + final input = ArgvInput.parse(parser, [ + 'Monitor', + '--kind=action', + ]); + final output = BufferedOutput(); + final code = await cmd.handle(ArtisanContext.bare(input, output)); + + expect(code, 1); + expect(output.content, contains('pubspec.yaml')); + }, + ); + + test( + 'returns 1 when the test file already exists without --force', + () async { + final cmd = MakeTestCommand(testRoot: projectRoot.path); + await cmd.handle( + buildCommandContext(cmd, ['Monitor', '--kind=action']), + ); + + final second = MakeTestCommand(testRoot: projectRoot.path); + final code = await second.handle( + buildCommandContext(second, ['Monitor', '--kind=action']), + ); + expect(code, 1); + }, + ); + }); +} diff --git a/test/cli/commands/make_view_command_test.dart b/test/cli/commands/make_view_command_test.dart new file mode 100644 index 00000000..58678109 --- /dev/null +++ b/test/cli/commands/make_view_command_test.dart @@ -0,0 +1,227 @@ +import 'dart:io'; + +import 'package:fluttersdk_artisan/artisan.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/src/cli/commands/make_view_command.dart'; +import 'package:path/path.dart' as p; + +import '_harness.dart'; + +void main() { + group('MakeViewCommand metadata', () { + final cmd = MakeViewCommand(); + + test('declares name make:view', () { + expect(cmd.name, 'make:view'); + }); + + test('extends ArtisanGeneratorCommand', () { + expect(cmd, isA()); + }); + + test('declares CommandBoot.none', () { + expect(cmd.boot, CommandBoot.none); + }); + }); + + group('MakeViewCommand.handle()', () { + late Directory projectRoot; + + setUp(() { + projectRoot = Directory.systemTemp.createTempSync('make_view_'); + File( + p.join(projectRoot.path, 'pubspec.yaml'), + ).writeAsStringSync('name: fixture_app\n'); + }); + + tearDown(() { + if (projectRoot.existsSync()) projectRoot.deleteSync(recursive: true); + }); + + String readFile(List segments) => + File(p.joinAll([projectRoot.path, ...segments])).readAsStringSync(); + + test('no flag keeps the stateless view', () async { + final cmd = MakeViewCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['Login']), + ); + + expect(code, 0); + final content = readFile([ + 'lib', + 'resources', + 'views', + 'login_view.dart', + ]); + expect(content, contains('class LoginView extends StatelessWidget')); + expect(content, isNot(contains('MagicStatefulView'))); + }); + + test('--controller emits a MagicStatefulView bound to it', () async { + final cmd = MakeViewCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['Monitor', '--controller=Monitor']), + ); + + expect(code, 0); + final content = readFile([ + 'lib', + 'resources', + 'views', + 'monitor_view.dart', + ]); + expect( + content, + contains( + 'class MonitorView extends MagicStatefulView', + ), + ); + expect( + content, + contains( + 'class _MonitorViewState\n' + ' extends MagicStatefulViewState {', + ), + ); + expect( + content, + contains("import '../../app/controllers/monitor_controller.dart';"), + ); + expect(content, contains('Magic.findOrPut(MonitorController.new);')); + }); + + test('--stateful derives the controller from the view name', () async { + final cmd = MakeViewCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['Login', '--stateful']), + ); + + expect(code, 0); + final content = readFile([ + 'lib', + 'resources', + 'views', + 'login_view.dart', + ]); + expect( + content, + contains('class LoginView extends MagicStatefulView'), + ); + expect( + content, + contains("import '../../app/controllers/login_controller.dart';"), + ); + }); + + test( + '--list adds RefetchesOnMount and delegates to controller.ensureFresh()', + () async { + final cmd = MakeViewCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, [ + 'Monitor', + '--controller=Monitor', + '--list', + ]), + ); + + expect(code, 0); + final content = readFile([ + 'lib', + 'resources', + 'views', + 'monitor_view.dart', + ]); + expect( + content, + contains('with RefetchesOnMount'), + ); + expect( + content, + contains('Future refetch() => controller.ensureFresh();'), + ); + }, + ); + + test('--form adds a State-owned form field disposed in onClose', () async { + final cmd = MakeViewCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, [ + 'Monitor', + '--controller=Monitor', + '--form=MonitorFormObject', + ]), + ); + + expect(code, 0); + final content = readFile([ + 'lib', + 'resources', + 'views', + 'monitor_view.dart', + ]); + expect( + content, + contains("import '../../app/forms/monitor_form_object.dart';"), + ); + expect(content, contains('late final form = MonitorFormObject();')); + expect(content, contains('void onClose() {\n form.dispose();\n }')); + }); + + test( + 'a nested view name computes the relative controller import from its depth', + () async { + final cmd = MakeViewCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, [ + 'Monitors/MonitorsList', + '--controller=Monitor', + ]), + ); + + expect(code, 0); + final content = readFile([ + 'lib', + 'resources', + 'views', + 'monitors', + 'monitors_list_view.dart', + ]); + expect( + content, + contains( + "import '../../../app/controllers/monitor_controller.dart';", + ), + ); + }, + ); + + test('--test chains make:test --kind=view', () async { + final cmd = MakeViewCommand(testRoot: projectRoot.path); + final code = await cmd.handle( + buildCommandContext(cmd, ['Monitor', '--test']), + ); + + expect(code, 0); + expect( + File( + p.join( + projectRoot.path, + 'test', + 'resources', + 'views', + 'monitor_view_test.dart', + ), + ).existsSync(), + isTrue, + ); + }); + + test('returns 1 when the name argument is missing', () async { + final cmd = MakeViewCommand(testRoot: projectRoot.path); + final code = await cmd.handle(buildCommandContext(cmd, [])); + expect(code, 1); + }); + }); +} diff --git a/test/cli/docs_generator_count_test.dart b/test/cli/docs_generator_count_test.dart new file mode 100644 index 00000000..26bf1699 --- /dev/null +++ b/test/cli/docs_generator_count_test.dart @@ -0,0 +1,78 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:magic/src/cli/magic_artisan_provider.dart'; +import 'package:path/path.dart' as p; + +/// Matches a stated generator count next to the phrase it is documented in, +/// e.g. `20 make:* generators` or `` 20 `make:*` scaffold commands ``. +/// +/// Backticks around `make:*` are optional so the same pattern matches plain +/// prose and Markdown code-span formatting alike. +final RegExp _countPhrase = RegExp( + r'(\d+)\s*`?make:\*`?\s*(?:generators|scaffold commands)', +); + +/// `flutter test` runs with the package root as the working directory, so +/// [Directory.current] is the reliable resolution mechanism (mirrors +/// `test/cli/commands/magic_install_command_test.dart`'s `_magicRoot`). +String get _repoRoot => Directory.current.path; + +/// Reads [relativePath] (relative to the package root) as a string. +String _read(String relativePath) => + File(p.join(_repoRoot, relativePath)).readAsStringSync(); + +/// Extracts the single generator count [_countPhrase] finds in [content], +/// failing loudly (rather than silently passing on zero matches) when the +/// phrase is missing or stated more than once. +int _statedCount(String content, String label) { + final matches = _countPhrase.allMatches(content).toList(); + expect( + matches, + hasLength(1), + reason: + '$label should state the make:* generator count exactly once, ' + 'matching " make:* generators" or " `make:*` scaffold commands".', + ); + return int.parse(matches.single.group(1)!); +} + +void main() { + group('make:* generator count parity', () { + late int actualCount; + + setUpAll(() { + final commands = MagicArtisanProvider().commands(); + actualCount = commands.where((c) => c.name.startsWith('make:')).length; + }); + + test('the provider registers at least one make:* command', () { + expect(actualCount, greaterThan(0)); + }); + + test('README.md states the current make:* generator count', () { + final stated = _statedCount(_read('README.md'), 'README.md'); + expect(stated, actualCount); + }); + + test( + 'doc/packages/magic-cli.md states the current make:* generator count', + () { + final stated = _statedCount( + _read('doc/packages/magic-cli.md'), + 'doc/packages/magic-cli.md', + ); + expect(stated, actualCount); + }, + ); + + test('the MagicArtisanProvider docblock states the current make:* ' + 'generator count', () { + final stated = _statedCount( + _read('lib/src/cli/magic_artisan_provider.dart'), + 'lib/src/cli/magic_artisan_provider.dart', + ); + expect(stated, actualCount); + }); + }); +} diff --git a/test/cli/integration/generated_code_analyzes_test.dart b/test/cli/integration/generated_code_analyzes_test.dart new file mode 100644 index 00000000..96056411 --- /dev/null +++ b/test/cli/integration/generated_code_analyzes_test.dart @@ -0,0 +1,219 @@ +@Tags(['integration']) +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fluttersdk_artisan/artisan.dart'; +import 'package:path/path.dart' as p; + +import 'package:magic/src/cli/commands/make_action_command.dart'; +import 'package:magic/src/cli/commands/make_component_command.dart'; +import 'package:magic/src/cli/commands/make_controller_command.dart'; +import 'package:magic/src/cli/commands/make_enum_command.dart'; +import 'package:magic/src/cli/commands/make_event_command.dart'; +import 'package:magic/src/cli/commands/make_form_command.dart'; +import 'package:magic/src/cli/commands/make_lang_command.dart'; +import 'package:magic/src/cli/commands/make_listener_command.dart'; +import 'package:magic/src/cli/commands/make_middleware_command.dart'; +import 'package:magic/src/cli/commands/make_migration_command.dart'; +import 'package:magic/src/cli/commands/make_model_command.dart'; +import 'package:magic/src/cli/commands/make_policy_command.dart'; +import 'package:magic/src/cli/commands/make_provider_command.dart'; +import 'package:magic/src/cli/commands/make_repository_command.dart'; +import 'package:magic/src/cli/commands/make_request_command.dart'; +import 'package:magic/src/cli/commands/make_resource_command.dart'; +import 'package:magic/src/cli/commands/make_seeder_command.dart'; +import 'package:magic/src/cli/commands/make_test_command.dart'; +import 'package:magic/src/cli/commands/make_view_command.dart'; + +import '../commands/_harness.dart'; + +/// `flutter test` runs with the package root as the working directory (see +/// `test/cli/docs_generator_count_test.dart`'s `_repoRoot`), which is also +/// where [MagicStubLoader] resolves `assets/stubs/` from via +/// `.dart_tool/package_config.json`'s self-referencing `magic` entry. That +/// resolution is untouched by the generators writing into [_ProbeProject]; +/// only their OUTPUT path is redirected there via each command's `testRoot`. +String get _magicRoot => Directory.current.path; + +/// Runs [command] against [args] through the shared harness, failing loudly +/// (with the command name and args) on a non-zero exit code instead of +/// leaving a missing file to surface as a confusing analyzer error later. +Future _run(ArtisanCommand command, List args) async { + final int code = await command.handle(buildCommandContext(command, args)); + expect( + code, + 0, + reason: 'artisan ${command.name} ${args.join(' ')} exited $code', + ); +} + +void main() { + late Directory probeRoot; + + setUpAll(() async { + // 1. Scaffold a throwaway Flutter package (`gen_probe`) the generators + // write into: a path dependency on magic's own worktree root, plus + // flutter_lints wired the same way a real consumer would. + probeRoot = Directory.systemTemp.createTempSync('gen_probe_'); + _writeProbePubspec(probeRoot.path, magicRoot: _magicRoot); + _writeProbeAnalysisOptions(probeRoot.path); + _seedEnglishLangFile(probeRoot.path); + + // 2. Run every generator in-process, exactly the combination a developer + // scaffolding a new vertical plus a handful of standalone pieces would + // invoke from their own terminal. + await _generateEverything(probeRoot.path); + + // 3. Resolve the path dependency (offline first; CI runners without a + // warm pub cache for magic's own transitive deps fall back online). + await _pubGet(probeRoot.path); + }); + + tearDownAll(() { + if (probeRoot.existsSync()) probeRoot.deleteSync(recursive: true); + }); + + test('every make:* generator output analyzes with flutter_lints, zero ' + 'issues', () async { + final ProcessResult result = await Process.run('flutter', [ + 'analyze', + ], workingDirectory: probeRoot.path); + final String output = '${result.stdout}${result.stderr}'; + + expect(output, contains('No issues found!'), reason: output); + }); +} + +/// Writes `gen_probe`'s `pubspec.yaml`: a path dependency on [magicRoot] (an +/// ABSOLUTE path, so pub resolves it regardless of where the temp directory +/// landed), plus `flutter_test`/`flutter_lints` the same way any consumer +/// project wires them. +void _writeProbePubspec(String root, {required String magicRoot}) { + final String pubspec = + ''' +name: gen_probe +description: Throwaway probe project proving every make:* generator's output analyzes clean. +publish_to: none +version: 0.0.1 + +environment: + sdk: ">=3.11.0 <4.0.0" + flutter: ">=3.41.0" + +dependencies: + flutter: + sdk: flutter + magic: + path: $magicRoot + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^6.0.0 +'''; + FileHelper.writeFile(p.join(root, 'pubspec.yaml'), pubspec); +} + +/// Writes `gen_probe`'s `analysis_options.yaml`: the plain `flutter_lints` +/// ruleset a fresh consumer project ships, no magic-specific overrides. +void _writeProbeAnalysisOptions(String root) { + FileHelper.writeFile( + p.join(root, 'analysis_options.yaml'), + 'include: package:flutter_lints/flutter.yaml\n', + ); +} + +/// Seeds `assets/lang/en.json` so `make:lang tr` copies a real key tree +/// instead of falling back to `{}` (both are valid JSON; this exercises the +/// copy path). +void _seedEnglishLangFile(String root) { + final String json = const JsonEncoder.withIndent( + ' ', + ).convert({'welcome': 'Welcome'}); + FileHelper.writeFile(p.join(root, 'assets', 'lang', 'en.json'), json); +} + +/// Runs every `make:*` generator this step covers, in dependency order, into +/// [root]: `make:resource` composes its own CRUD vertical (with its own +/// chained tests); the standalone `Probe` model plus one source file per +/// `TestKind` gives every `make:test --kind=` call a real class to +/// import: a kind with no matching source would otherwise fail the analyzer +/// on a missing import, which is not a generator defect. +Future _generateEverything(String root) async { + // 1. The composed CRUD vertical. + await _run(MakeResourceCommand(testRoot: root), ['Monitor']); + + // 2. Standalone pieces with no dependency on the vertical above. + await _run(MakeComponentCommand(testRoot: root), ['Badge']); + await _run(MakeEnumCommand(testRoot: root), ['Region', '--wire']); + + // 3. A second model, `Probe`, plus one source file per TestKind so every + // `make:test Probe --kind=` below imports a real class. + await _run(MakeModelCommand(testRoot: root), ['Probe']); + await _run(MakeControllerCommand(testRoot: root), ['Probe']); + await _run(MakeActionCommand(testRoot: root), ['Probe']); + await _run(MakeFormCommand(testRoot: root), ['Probe']); + await _run(MakeRepositoryCommand(testRoot: root), ['Probe']); + await _run(MakeRequestCommand(testRoot: root), ['Probe']); + await _run(MakeViewCommand(testRoot: root), [ + 'Probe', + '--controller=Probe', + ]); + + // 4. `make:test Probe --kind=` for every TestKind, now that each + // kind's source class exists. + for (final TestKind kind in TestKind.values) { + await _run(MakeTestCommand(testRoot: root), [ + 'Probe', + '--kind=${kind.value}', + ]); + } + + // 5. The remaining generators this step covers, none of which chains a + // matching test. + await _run(MakeEventCommand(testRoot: root), ['ProbeCreated']); + await _run(MakeListenerCommand(testRoot: root), [ + 'LogProbeCreated', + '--event=ProbeCreated', + ]); + await _run(MakeMiddlewareCommand(testRoot: root), [ + 'EnsureProbeReady', + ]); + await _run(MakeProviderCommand(testRoot: root), ['Probe']); + await _run(MakePolicyCommand(testRoot: root), ['Probe']); + await _run(MakeSeederCommand(testRoot: root), ['Probe']); + await _run(MakeMigrationCommand(testRoot: root), [ + 'create_probes_table', + '--create=probes', + ]); + await _run(MakeLangCommand(testRoot: root), ['tr']); +} + +/// Runs `flutter pub get --offline` in [root]; falls back to an online +/// `flutter pub get` when the offline cache cannot resolve every transitive +/// dependency (a cold pub cache, or a runner that never fetched +/// `fluttersdk_wind`/`fluttersdk_artisan` before). +Future _pubGet(String root) async { + final ProcessResult offline = await Process.run('flutter', [ + 'pub', + 'get', + '--offline', + ], workingDirectory: root); + if (offline.exitCode == 0) return; + + final ProcessResult online = await Process.run('flutter', [ + 'pub', + 'get', + ], workingDirectory: root); + expect( + online.exitCode, + 0, + reason: + 'flutter pub get failed offline and online:\n' + 'offline: ${offline.stdout}${offline.stderr}\n' + 'online: ${online.stdout}${online.stderr}', + ); +}