From 102924ba9998476d85d159e2fb1ad2702229240c Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Thu, 23 Jul 2026 15:11:59 -0400 Subject: [PATCH] fix(mix_generator): support @MixWidget factories returning same-build @MixableSpec stylers A @MixWidget factory returning a Styler generated by @MixableSpec in the same build failed clean builds with "@MixWidget target must resolve to a Style subtype, but got InvalidType": the generated Styler only exists in the combined .g.dart part, which never exists while generators run. When the written return type names an unresolved Styler whose name is derived from a same-library @MixableSpec class, derive the widget call surface from the @MixableSpec(target:) constructor - the same source SpecStylerGenerator uses to emit the styler's call() - keeping generation single-pass. Unmatched unresolved types keep the original diagnostic; a matching spec without target: gets an actionable error. --- .../src/core/helpers/widget_call_planner.dart | 35 +- .../lib/src/mix_widget_generator.dart | 258 +++++++++-- .../mix_widget_spec_styler_test.dart | 403 ++++++++++++++++++ 3 files changed, 652 insertions(+), 44 deletions(-) create mode 100644 packages/mix_generator/test/integration/mix_widget_spec_styler_test.dart diff --git a/packages/mix_generator/lib/src/core/helpers/widget_call_planner.dart b/packages/mix_generator/lib/src/core/helpers/widget_call_planner.dart index e5b365f79..df44d032a 100644 --- a/packages/mix_generator/lib/src/core/helpers/widget_call_planner.dart +++ b/packages/mix_generator/lib/src/core/helpers/widget_call_planner.dart @@ -20,6 +20,11 @@ const reservedParamNames = { 'noSuchMethod', }; +/// Target constructor parameters backed by the styler itself; a generated +/// `call()` passes `style: this`, so they never surface as widget-facing +/// parameters. +const stylerBackedTargetParams = {'style', 'styleSpec'}; + /// Builds the widget-facing `call()` method configured by /// `@MixableSpec(target:)`. /// @@ -40,15 +45,7 @@ String? buildMixableSpecTargetCall({ final target = annotation.peek('target'); if (target == null || target.isNull) return null; - final constructor = target.objectValue.toFunctionValue(); - if (constructor is! ConstructorElement) { - fail( - specElement, - '@MixableSpec(target:) must be a constructor tear-off ' - '(e.g., Box.new).', - ); - } - + final constructor = mixableSpecTargetTearOff(target, specElement); final widgetName = mixableSpecTargetWidgetName(constructor); if (validateTargetVisibility) { final widgetElement = constructor.enclosingElement; @@ -80,7 +77,7 @@ String? buildMixableSpecTargetCall({ anchor: hostElement, library: hostLibrary, factoryReference: stylerName, - excludeNames: const {'style', 'styleSpec'}, + excludeNames: stylerBackedTargetParams, annotationLabel: '@MixableSpec(target:)', keyOwner: 'the target constructor', ); @@ -104,6 +101,24 @@ List optionalPositionalNames( .toList(); } +/// Resolves `@MixableSpec(target:)` to its constructor tear-off, failing on +/// any other constant shape. +ConstructorElement mixableSpecTargetTearOff( + ConstantReader target, + InterfaceElement specElement, +) { + final constructor = target.objectValue.toFunctionValue(); + if (constructor is! ConstructorElement) { + fail( + specElement, + '@MixableSpec(target:) must be a constructor tear-off ' + '(e.g., Box.new).', + ); + } + + return constructor; +} + String mixableSpecTargetWidgetName(ConstructorElement constructor) { return requireName( constructor.enclosingElement, diff --git a/packages/mix_generator/lib/src/mix_widget_generator.dart b/packages/mix_generator/lib/src/mix_widget_generator.dart index d4b0c05ab..6981bf13e 100644 --- a/packages/mix_generator/lib/src/mix_widget_generator.dart +++ b/packages/mix_generator/lib/src/mix_widget_generator.dart @@ -11,6 +11,7 @@ library; import 'dart:convert'; +import 'package:analyzer/dart/ast/ast.dart'; import 'package:analyzer/dart/element/element.dart'; import 'package:analyzer/dart/element/type.dart'; import 'package:build/build.dart'; @@ -23,6 +24,7 @@ import 'core/errors.dart'; import 'core/helpers/library_scope.dart'; import 'core/helpers/type_hierarchy.dart'; import 'core/helpers/widget_call_planner.dart'; +import 'core/models/field_model.dart' show deriveStylerName; import 'core/models/mix_widget_model.dart'; const _annotationLabel = '@MixWidget'; @@ -111,6 +113,7 @@ class MixWidgetGenerator extends GeneratorForAnnotation { TopLevelVariableElement variable, ConstantReader annotation, _WidgetParameterSelection widgetParameters, + String? writtenStylerName, ) { final variableName = requireName( variable, @@ -118,19 +121,20 @@ class MixWidgetGenerator extends GeneratorForAnnotation { ); final library = variable.library; - final stylerType = _requireStyleInterface(variable, variable.type); - final callMethod = _requireCallMethod( - variable, - stylerType, + final callSource = _resolveCallSource( + anchor: variable, + stylerType: variable.type, + writtenStylerName: writtenStylerName, library: library, ); - _requireUnprefixedFlutterSymbols(variable, callMethod, library); + _requireUnprefixedFlutterSymbols(variable, callSource.call, library); final call = _extractWidgetCallParams( - callMethod, + callSource.call, anchor: variable, library: library, factoryReference: variableName, widgetParameters: widgetParameters, + baseExcluded: callSource.baseExcluded, ); return MixWidgetModel( @@ -144,7 +148,9 @@ class MixWidgetGenerator extends GeneratorForAnnotation { isFunctionFactory: false, factoryParams: const [], callParams: call.params, - callTypeParams: _extractCallTypeParams(callMethod, library: library), + callTypeParams: callSource.isGenerated + ? const [] + : _extractCallTypeParams(callSource.call, library: library), stylerCallForwardsKey: call.forwardsKey, doc: variable.documentationComment, ); @@ -154,6 +160,7 @@ class MixWidgetGenerator extends GeneratorForAnnotation { TopLevelFunctionElement function, ConstantReader annotation, _WidgetParameterSelection widgetParameters, + String? writtenStylerName, ) { final functionName = requireName( function, @@ -169,14 +176,14 @@ class MixWidgetGenerator extends GeneratorForAnnotation { } final library = function.library; - final stylerType = _requireStyleInterface(function, function.returnType); - final callMethod = _requireCallMethod( - function, - stylerType, + final callSource = _resolveCallSource( + anchor: function, + stylerType: function.returnType, + writtenStylerName: writtenStylerName, library: library, ); - _requireUnprefixedFlutterSymbols(function, callMethod, library); + _requireUnprefixedFlutterSymbols(function, callSource.call, library); final factoryParams = _extractFactoryParams( function, @@ -184,16 +191,19 @@ class MixWidgetGenerator extends GeneratorForAnnotation { factoryReference: functionName, ); final call = _extractWidgetCallParams( - callMethod, + callSource.call, anchor: function, library: library, factoryReference: functionName, widgetParameters: widgetParameters, + baseExcluded: callSource.baseExcluded, ); _rejectCollisions(function, factoryParams, call.params); - final callTypeParams = _extractCallTypeParams(callMethod, library: library); + final callTypeParams = callSource.isGenerated + ? const [] + : _extractCallTypeParams(callSource.call, library: library); final variantConstructors = _extractVariantConstructors( function, library: library, @@ -297,31 +307,145 @@ class MixWidgetGenerator extends GeneratorForAnnotation { String _dartStringLiteral(String value) => jsonEncode(value).replaceAll(r'$', r'\$'); - /// Confirms [type] is a `Style` subtype and returns the styler interface. - InterfaceType _requireStyleInterface(Element anchor, DartType type) { - if (type is! InterfaceType) { + /// Resolves the executable whose parameters define the generated widget's + /// `call()`-facing surface. + /// + /// The common path is the styler's own resolvable `call()` method. When the + /// factory returns a Styler that `@MixableSpec` generates in the *same* + /// build, that Styler is invisible to the resolver — its declaration only + /// exists in the combined `.g.dart` part emitted after this generator runs — + /// so [stylerType] resolves to `InvalidType`. In that case the widget's call + /// surface is exactly the `@MixableSpec(target:)` constructor that + /// `SpecStylerGenerator` turns into `.call()`, derived here from the + /// same source so generation stays single-pass. + _CallSource _resolveCallSource({ + required Element anchor, + required DartType stylerType, + required String? writtenStylerName, + required LibraryElement library, + }) { + if (stylerType is InterfaceType && + findSupertypeMatching(stylerType, styleChecker) != null) { + return _CallSource( + call: _requireCallMethod(anchor, stylerType, library: library), + baseExcluded: const {}, + isGenerated: false, + ); + } + + final target = _generatedStylerTargetConstructor( + anchor, + stylerType, + writtenStylerName, + library, + ); + if (target != null) { + return _CallSource( + call: target, + baseExcluded: stylerBackedTargetParams, + isGenerated: true, + ); + } + + // No same-build generated Styler matched: surface the original diagnostic + // (`InvalidType` / `does not extend Style`) unchanged. + _rejectNonStylerType(anchor, stylerType); + } + + /// Returns the `@MixableSpec(target:)` constructor backing a same-build + /// generated Styler named [writtenStylerName], or `null` when the factory + /// return type is not an unresolved same-library generated Styler. + ConstructorElement? _generatedStylerTargetConstructor( + Element anchor, + DartType stylerType, + String? writtenStylerName, + LibraryElement library, + ) { + // A resolvable interface is a real (mis)typed styler, not a generated one. + if (stylerType is InterfaceType || writtenStylerName == null) return null; + + final specElement = _findGeneratedStylerSpec(library, writtenStylerName); + if (specElement == null) return null; + + final specName = specElement.name!; + final annotationObject = mixableSpecAnnotationChecker.firstAnnotationOf( + specElement, + ); + if (annotationObject == null) return null; + + final target = ConstantReader(annotationObject).peek('target'); + if (target == null || target.isNull) { fail( anchor, - '$_annotationLabel target must resolve to a Style subtype, but ' - 'got `${type.getDisplayString()}`.', + '$_annotationLabel factory returns the same-build generated styler ' + '`$writtenStylerName`, but `@MixableSpec` on `$specName` declares no ' + '`target:`, so the generated styler has no `call()` to drive the ' + 'widget.', todo: - 'Declare a static type extending `Style` on the variable ' - 'or as the function return type.', + 'Add `@MixableSpec(target: YourWidget.new)` to `$specName`, or ' + 'return a styler that declares a `call()` method.', ); } - if (findSupertypeMatching(type, styleChecker) == null) { + final constructor = mixableSpecTargetTearOff(target, specElement); + + validateMixableSpecTargetConstructor( + constructor: constructor, + widgetName: mixableSpecTargetWidgetName(constructor), + specElement: specElement, + specName: specName, + anchor: anchor, + ); + + return constructor; + } + + /// Finds the `@MixableSpec` class in [library] whose conventional Styler + /// name equals [stylerName] (e.g. `ButtonSpec` -> `ButtonStyler`). + /// + /// Mirrors how `SpecStylerGenerator` links nested specs to their generated + /// stylers by name convention, because same-build generated stylers cannot + /// be resolved while a generator runs. + ClassElement? _findGeneratedStylerSpec( + LibraryElement library, + String stylerName, + ) { + for (final classElement in library.classes) { + final name = classElement.name; + if (name == null || deriveStylerName(name) != stylerName) continue; + if (mixableSpecAnnotationChecker.firstAnnotationOf(classElement) == + null) { + continue; + } + + return classElement; + } + + return null; + } + + /// Rejects a factory type that is neither a resolvable `Style` subtype + /// nor a same-build generated Styler, with the diagnostic matched to why. + Never _rejectNonStylerType(Element anchor, DartType type) { + if (type is! InterfaceType) { fail( anchor, '$_annotationLabel target must resolve to a Style subtype, but ' - '`${type.getDisplayString()}` does not extend Style.', + 'got `${type.getDisplayString()}`.', todo: - 'Use a styler that extends `Style` (e.g. `BoxStyler`, ' - '`TextStyler`).', + 'Declare a static type extending `Style` on the variable ' + 'or as the function return type.', ); } - return type; + fail( + anchor, + '$_annotationLabel target must resolve to a Style subtype, but ' + '`${type.getDisplayString()}` does not extend Style.', + todo: + 'Use a styler that extends `Style` (e.g. `BoxStyler`, ' + '`TextStyler`).', + ); } /// Looks up the styler's `call()` method (including inherited members) and @@ -384,18 +508,24 @@ class MixWidgetGenerator extends GeneratorForAnnotation { /// visibility, and collision validation. `key` remains automatic and is /// therefore always validated, even in an empty `only` selection. ({List params, bool forwardsKey}) _extractWidgetCallParams( - MethodElement callMethod, { + ExecutableElement callMethod, { required Element anchor, required LibraryElement library, required String factoryReference, required _WidgetParameterSelection widgetParameters, + Set baseExcluded = const {}, }) { + // [baseExcluded] names never surface as widget parameters (a generated + // styler's `style`/`styleSpec`); they are filtered out before curation, + // required-parameter, and optional-positional validation. final excludedNames = {}; if (!widgetParameters.includesAll) { final availableNames = { for (final parameter in callMethod.formalParameters) - if (parameter.name case final String name) name, + if (parameter.name case final String name + when !baseExcluded.contains(name)) + name, }; for (final name in widgetParameters.names) { @@ -422,6 +552,7 @@ class MixWidgetGenerator extends GeneratorForAnnotation { final name = parameter.name; if (name == 'key' || name == null || + baseExcluded.contains(name) || widgetParameters.names.contains(name)) { continue; } @@ -445,7 +576,8 @@ class MixWidgetGenerator extends GeneratorForAnnotation { for (final parameter in callMethod.formalParameters) if (parameter.name == 'key' || parameter.name == null || - !excludedNames.contains(parameter.name)) + (!excludedNames.contains(parameter.name) && + !baseExcluded.contains(parameter.name))) parameter, ]; @@ -468,7 +600,7 @@ class MixWidgetGenerator extends GeneratorForAnnotation { anchor: anchor, library: library, factoryReference: factoryReference, - excludeNames: excludedNames, + excludeNames: {...excludedNames, ...baseExcluded}, ); } @@ -487,7 +619,7 @@ class MixWidgetGenerator extends GeneratorForAnnotation { } List _extractCallTypeParams( - MethodElement callMethod, { + ExecutableElement callMethod, { required LibraryElement library, }) { return [ @@ -598,7 +730,7 @@ class MixWidgetGenerator extends GeneratorForAnnotation { void _requireUnprefixedFlutterSymbols( Element anchor, - MethodElement callMethod, + ExecutableElement callMethod, LibraryElement hostLibrary, ) { final returnType = _widgetAssignableReturnType(callMethod.returnType); @@ -737,23 +869,59 @@ class MixWidgetGenerator extends GeneratorForAnnotation { return '${'_' * leadingUnderscores}$pascal'; } + /// The syntactic return/variable type name of the annotated factory, read + /// from the AST. + /// + /// A same-build generated Styler resolves to `InvalidType`, so its element + /// type carries no usable name; the written annotation still does. Returns + /// `null` when there is no explicit unprefixed type annotation (an inferred + /// variable, or a prefixed cross-library reference this pass cannot derive). + Future _writtenStylerTypeName( + Element element, + BuildStep buildStep, + ) async { + final node = await buildStep.resolver.astNodeFor( + element.firstFragment, + resolve: false, + ); + + TypeAnnotation? annotationType; + if (node is FunctionDeclaration) { + annotationType = node.returnType; + } else if (node is VariableDeclaration) { + final declarationList = node.parent; + if (declarationList is VariableDeclarationList) { + annotationType = declarationList.type; + } + } + + if (annotationType is! NamedType || annotationType.importPrefix != null) { + return null; + } + + return annotationType.name.lexeme; + } + @override - String generateForAnnotatedElement( + Future generateForAnnotatedElement( Element element, ConstantReader annotation, BuildStep buildStep, - ) { + ) async { final widgetParameters = _widgetParameterSelectionFor(annotation); + final writtenStylerName = await _writtenStylerTypeName(element, buildStep); final model = switch (element) { TopLevelVariableElement v => _modelForVariable( v, annotation, widgetParameters, + writtenStylerName, ), TopLevelFunctionElement f => _modelForFunction( f, annotation, widgetParameters, + writtenStylerName, ), _ => fail( element, @@ -765,3 +933,25 @@ class MixWidgetGenerator extends GeneratorForAnnotation { return MixWidgetBuilder(model).build(); } } + +/// The executable whose parameters define a generated widget's `call()` +/// surface, plus how to interpret them. +class _CallSource { + /// The styler `call()` method, or the `@MixableSpec(target:)` constructor + /// when the factory returns a same-build generated Styler. + final ExecutableElement call; + + /// Parameter names that never surface on the widget (a generated styler's + /// `style`/`styleSpec`). + final Set baseExcluded; + + /// Whether [call] is a generated styler's target constructor rather than a + /// resolvable `call()` method. + final bool isGenerated; + + const _CallSource({ + required this.call, + required this.baseExcluded, + required this.isGenerated, + }); +} diff --git a/packages/mix_generator/test/integration/mix_widget_spec_styler_test.dart b/packages/mix_generator/test/integration/mix_widget_spec_styler_test.dart new file mode 100644 index 000000000..1ade357cb --- /dev/null +++ b/packages/mix_generator/test/integration/mix_widget_spec_styler_test.dart @@ -0,0 +1,403 @@ +/// Clean-build coverage for `@MixWidget` factories that return a Styler +/// generated by `@MixableSpec` in the same build. +/// +/// The generated Styler (`ButtonStyler`) only exists in the combined +/// `.g.dart` part, so it never resolves while `MixWidgetGenerator` runs — the +/// factory return type is `InvalidType`. These tests run both generators in +/// one `PartBuilder` (the same shape as the real `combining_builder` +/// pipeline) and assert the combined output resolves, exercising enum variant +/// constructors and `widgetParameters` curation on the generated-styler path. +library; + +import 'package:build/build.dart'; +import 'package:build_test/build_test.dart'; +import 'package:mix_generator/mix_generator.dart'; +import 'package:source_gen/source_gen.dart'; +import 'package:test/test.dart'; + +import '../core/test_helpers.dart'; + +/// `package:mix` stub surface (Spec/Style/MixStyler/StyleWidget/Prop/MixOps) +/// declared at the real paths so the URL-based type checkers match. +const _mixStub = r''' + import 'package:flutter/foundation.dart'; + import 'src/core/mix_element.dart'; + import 'src/core/style_spec.dart'; + + export 'src/core/mix_element.dart'; + export 'src/core/style_spec.dart'; + + abstract class Spec> { + const Spec(); + } + + abstract class Style> extends Mix> { + final List>? $variants; + final WidgetModifierConfig? $modifier; + final AnimationConfig? $animation; + + const Style({ + List>? variants, + WidgetModifierConfig? modifier, + AnimationConfig? animation, + }) : $variants = variants, + $modifier = modifier, + $animation = animation; + } + + abstract class MixStyler, SP extends Spec> + extends Style with Diagnosticable { + const MixStyler({super.variants, super.modifier, super.animation}); + } + + class AnimationConfig {} + class WidgetModifierConfig { + Object? resolve(Object context) => null; + } + class VariantStyle> {} + + class Prop { + static Prop? maybe(T? value) => null; + static Prop? maybeMix(Object? value) => null; + static Prop? mix(Object value) => null; + } + + class MixOps { + static Prop? merge(Prop? a, Prop? b) => null; + static List>? mergeVariants>( + List>? a, + List>? b, + ) => null; + static WidgetModifierConfig? mergeModifier( + WidgetModifierConfig? a, + WidgetModifierConfig? b, + ) => null; + static AnimationConfig? mergeAnimation( + AnimationConfig? a, + AnimationConfig? b, + ) => null; + static T? resolve(Object context, Prop? prop) => null; + } + + class Color { + const Color(int value); + } +'''; + +const _mixSources = { + 'mix|lib/mix.dart': _mixStub, + 'mix|lib/src/core/mix_element.dart': ''' + abstract class Mix { + const Mix(); + } + ''', + 'mix|lib/src/core/style_spec.dart': ''' + import '../../mix.dart'; + + class StyleSpec> { + const StyleSpec({ + required S spec, + Object? animation, + Object? widgetModifiers, + }); + } + ''', + 'mix|lib/src/core/style_widget.dart': ''' + import 'package:flutter/widgets.dart'; + import 'package:mix/mix.dart'; + + abstract class StyleWidget> extends Widget { + final Style style; + final StyleSpec? styleSpec; + + const StyleWidget({ + required this.style, + this.styleSpec, + super.key, + }); + } + ''', +}; + +const _flutterResolveStubs = { + 'flutter|lib/foundation.dart': ''' + class DiagnosticPropertiesBuilder { + void add(Object? property) {} + } + + class DiagnosticsProperty { + const DiagnosticsProperty(String name, T value); + } + + mixin Diagnosticable { + void debugFillProperties(DiagnosticPropertiesBuilder properties) {} + } + ''', + 'flutter|lib/src/foundation/key.dart': ''' + class Key { + const Key(); + } + ''', + 'flutter|lib/src/widgets/framework.dart': ''' + import '../foundation/key.dart'; + export '../foundation/key.dart'; + + abstract class Widget { + const Widget({this.key}); + final Key? key; + } + + abstract class StatelessWidget extends Widget { + const StatelessWidget({super.key}); + Widget build(BuildContext context); + } + + class BuildContext {} + ''', + 'flutter|lib/widgets.dart': ''' + export 'foundation.dart'; + export 'src/widgets/framework.dart'; + ''', +}; + +/// Combined `SpecStylerGenerator` + `MixWidgetGenerator` into one `.g.dart` +/// part — the same aggregation the real `combining_builder` performs, so the +/// generated `ButtonStyler` is only ever visible after both generators run. +Builder _combinedBuilder() => PartBuilder([ + const SpecStylerGenerator(), + const MixWidgetGenerator(), +], '.g.dart'); + +Map _sources(String body) => { + ...mixAnnotationsSources, + ..._flutterResolveStubs, + ..._mixSources, + 'mix|lib/button.dart': + ''' +library button; + +import 'package:flutter/widgets.dart'; +import 'package:mix/mix.dart'; +import 'package:mix/src/core/style_widget.dart'; +import 'package:mix_annotations/mix_annotations.dart'; + +part 'button.g.dart'; + +class ButtonWidget extends StyleWidget { + const ButtonWidget({ + super.key, + required super.style, + super.styleSpec, + required this.label, + this.child, + }); + + final String label; + final Widget? child; +} + +$body +''', +}; + +/// Runs only `MixWidgetGenerator` and returns its joined error output. +Future _expectMixWidgetError(String body) async { + final result = await testBuilder( + partBuilder(const MixWidgetGenerator()), + _sources(body), + generateFor: {'mix|lib/button.dart'}, + ); + + expect(result.succeeded, isFalse); + + return result.errors.join('\n'); +} + +void main() { + group('MixWidget consuming a same-build @MixableSpec styler', () { + test('generates the widget from the generated styler call surface', () async { + const body = r''' +@MixableSpec(target: ButtonWidget.new) +final class ButtonSpec extends Spec { + final Color? color; + const ButtonSpec({this.color}); +} + +@MixWidget() +ButtonStyler buttonStyle({Color? color}) => ButtonStyler(color: color); +'''; + + await expectGeneratorOutputResolves( + builder: _combinedBuilder(), + sources: _sources(body), + inputAsset: 'mix|lib/button.dart', + outputAsset: 'mix|lib/button.g.dart', + outputMatcher: allOf([ + // The generated styler and its target-derived call() live in the + // same part; the widget is generated despite ButtonStyler never + // resolving during this build. + contains( + 'class ButtonStyler extends MixStyler', + ), + contains( + 'ButtonWidget call({Key? key, required String label, Widget? child})', + ), + contains('class Button extends StatelessWidget {'), + // Factory param (color) + curated call params (label, child). + contains( + 'const Button({super.key, this.color, required this.label, ' + 'this.child});', + ), + contains('final Color? color;'), + contains('final String label;'), + contains('final Widget? child;'), + // build() delegates through the factory to the styler call(). + contains('buttonStyle('), + contains( + '.call(key: this.key, label: this.label, child: this.child)', + ), + ]), + ); + }); + + test( + 'supports enum variant constructors on the generated-styler path', + () async { + const body = r''' +enum ButtonVariant { solid, ghost } + +@MixableSpec(target: ButtonWidget.new) +final class ButtonSpec extends Spec { + final Color? color; + const ButtonSpec({this.color}); +} + +@MixWidget() +ButtonStyler buttonStyle({ + ButtonVariant variant = ButtonVariant.solid, + Color? color, +}) => ButtonStyler(color: color); +'''; + + await expectGeneratorOutputResolves( + builder: _combinedBuilder(), + sources: _sources(body), + inputAsset: 'mix|lib/button.dart', + outputAsset: 'mix|lib/button.g.dart', + outputMatcher: allOf([ + contains('class Button extends StatelessWidget {'), + contains('const Button.solid('), + contains('const Button.ghost('), + contains(': variant = ButtonVariant.solid;'), + contains('required this.label'), + ]), + ); + }, + ); + + test('supports a variable factory typed as the generated styler', () async { + const body = r''' +@MixableSpec(target: ButtonWidget.new) +final class ButtonSpec extends Spec { + final Color? color; + const ButtonSpec({this.color}); +} + +@MixWidget() +final ButtonStyler cardStyle = ButtonStyler(color: Color(0xFF000000)); +'''; + + await expectGeneratorOutputResolves( + builder: _combinedBuilder(), + sources: _sources(body), + inputAsset: 'mix|lib/button.dart', + outputAsset: 'mix|lib/button.g.dart', + outputMatcher: allOf([ + contains('class Card extends StatelessWidget {'), + contains( + 'cardStyle.call(key: this.key, label: this.label, ' + 'child: this.child)', + ), + ]), + ); + }); + + test( + 'supports widgetParameters curation on the generated-styler path', + () async { + const body = r''' +@MixableSpec(target: ButtonWidget.new) +final class ButtonSpec extends Spec { + final Color? color; + const ButtonSpec({this.color}); +} + +@MixWidget(widgetParameters: .only({'label'})) +ButtonStyler buttonStyle({Color? color}) => ButtonStyler(color: color); +'''; + + await expectGeneratorOutputResolves( + builder: _combinedBuilder(), + sources: _sources(body), + inputAsset: 'mix|lib/button.dart', + outputAsset: 'mix|lib/button.g.dart', + outputMatcher: allOf([ + contains('class Button extends StatelessWidget {'), + contains('required this.label'), + // `child` was excluded by the curation. + isNot(contains('this.child')), + ]), + ); + }, + ); + }); + + group('MixWidget generated-styler fallback diagnostics', () { + test('an unresolved return type with no matching spec keeps the original ' + 'InvalidType error', () async { + // `MysteryStyler` is neither in source nor derivable from any + // `@MixableSpec`, so the fallback must not fire — the original + // diagnostic has to stand. + const body = r''' +@MixWidget() +MysteryStyler mysteryStyle() => throw UnimplementedError(); +'''; + + final errors = await _expectMixWidgetError(body); + + expect( + errors, + contains( + '@MixWidget target must resolve to a Style subtype, but got ' + '`InvalidType`.', + ), + ); + }); + + test( + 'a generated styler whose spec has no target reports a clear error', + () async { + const body = r''' +@MixableSpec() +final class ButtonSpec extends Spec { + final Color? color; + const ButtonSpec({this.color}); +} + +@MixWidget() +ButtonStyler buttonStyle() => throw UnimplementedError(); +'''; + + final errors = await _expectMixWidgetError(body); + + expect( + errors, + allOf([ + contains('same-build generated styler `ButtonStyler`'), + contains('`@MixableSpec` on `ButtonSpec` declares no `target:`'), + ]), + ); + }, + ); + }); +}