import 'package:pipe/pipe.dart'; import 'package:test/test.dart'; const String fixturePath = 'test/fixtures/stuck_loading'; /// A rule's positive control: it MUST fire on this identity. typedef _Id = (String key, String value); /// Every rule must have both a positive control (recall) and a negative control /// (precision) drawn from an adjacent fixture case. A rule added without them /// fails the completeness test at the bottom. const Map _positives = { ruleEmittedNotRendered: ('union', 'ItemState'), ruleRenderedNotEmitted: ('union', 'GapState'), ruleLoadingNotReset: ('owner', 'ItemBloc'), ruleLoadingFlagNotReset: ('member', 'loadUnguarded'), ruleEventWithoutHandler: ('event', 'UnhandledTap'), ruleDeadHandler: ('event', 'NeverDispatched'), ruleRiverpodAsyncGap: ('provider', 'unhandledProvider'), ruleNotifierNeverNotifies: ('member', 'bumpWithoutNotifying'), ruleNotifierInert: ('owner', 'InertNotifier'), ruleProviderReadInBuild: ('valueType', 'LiveNotifier'), ruleNotifierFieldUnread: ('owner', 'PartiallyReadNotifier'), ruleProviderCreatedInBuild: ('valueTypes', 'LiveNotifier'), }; const Map> _negatives = { // PatternA/PatternB are emitted AND matched (via typed variable patterns), so // they are the real precision control here. Asserting on a variant that is // never emitted would prove nothing. ruleEmittedNotRendered: [('variants', 'PatternA'), ('variants', 'PatternB')], // PatternState HAS render sites and every variant it renders is emitted. // ItemState would be vacuous: it has no render sites, so it could never be // reported regardless of the emission logic. // DocLoading/DocReady are emitted through freezed-style redirecting factories // (`DocState.loading()`), which must be credited to the redirect target. ruleRenderedNotEmitted: [ ('union', 'PatternState'), ('variant', 'DocLoading'), ('variant', 'DocReady'), ], // GuardedCubit emits a loading variant and awaits inside a try whose catch // resolves it, so reporting it would be a false positive. FlagCubit would be // vacuous: it has no variant union and is skipped outright. ruleLoadingNotReset: [('owner', 'GuardedCubit')], // loadGuarded resolves the flag, refreshQuietly never sets it, and `_warmUp` // is constructor-invoked on a flag defaulting to FALSE, so nothing is in // flight. // scheduleGuarded's closure sets the flag and guards its own await, and // registerHandlers only registers closures: neither enclosing method is in // flight itself. Every closure gets a real name, never ``. ruleLoadingFlagNotReset: [ ('member', 'loadGuarded'), ('member', 'refreshQuietly'), ('member', '_warmUp'), ('member', 'scheduleGuarded'), ('member', 'registerHandlers'), ('member', ''), ], // HandledTap is handled AND dispatched, so it is the control that proves both // event rules actually consult the handler and dispatch information. // NeverDispatched (never handled) and UnhandledTap (never handled) were // vacuous. // SyncStarted/UploadRetryNow are handled through `on` and // `on`; those base and group handlers are live because a // subtype is dispatched (base_event.dart). ruleEventWithoutHandler: [ ('event', 'HandledTap'), ('event', 'SyncStarted'), ('event', 'UploadRetryNow'), // Handled by ShareBetaBloc, which shares ShareEvent with ShareAlphaBloc. ('event', 'ShareDismissed'), ], ruleDeadHandler: [ ('event', 'HandledTap'), ('event', 'SyncEvent'), ('event', 'UploadRetry'), ], // handledProvider covers all AsyncValue cases. // The others are handled with AsyncError/AsyncLoading patterns (switch // expression and statement) or through a stored variable, and each is also // read directly elsewhere, so a gap would be reported if the handling were // missed. ruleRiverpodAsyncGap: [ ('provider', 'handledProvider'), ('provider', 'patternedProvider'), ('provider', 'switchedProvider'), ('provider', 'storedProvider'), ], // `bump` notifies; `setQuiet` mutates but a notifying method calls it. ruleNotifierNeverNotifies: [('member', 'bump'), ('member', 'setQuiet')], // LiveNotifier notifies, so its Consumer can rebuild. ruleNotifierInert: [('owner', 'LiveNotifier')], // Reading an immutable service in build is correct; a rule that flags every // context.read would fire here and is wrong. ruleProviderReadInBuild: [('valueType', 'AppConfig')], // `value` IS displayed by the UI, so it must never be listed as unread. ruleNotifierFieldUnread: [('members', 'value')], // A provider for AppConfig is constructed in build via `.value` and one is // declared at top level; neither may be reported. ruleProviderCreatedInBuild: [('valueTypes', 'AppConfig')], }; void main() { late ProjectAnalysis analysis; late List findings; setUpAll(() async { final project = await loadProject(fixturePath); analysis = await analyzeProject(project, concurrency: 1); findings = analysis.report.findings; }); tearDownAll(() async { await analysis.dispose(); }); Iterable valuesOf(String rule, String key) sync* { for (final finding in findings.where((f) => f.rule == rule)) { final value = finding.data[key]; if (value is String) { yield value; } else if (value is List) { // Grouped findings carry a list (e.g. `variants`), so a negative // control on a grouped rule must inspect membership. for (final entry in value) { if (entry is String) yield entry; } } } } test('dead-handler: every bloc sharing an event type is checked', () { // Only the first owner of ShareEvent used to be kept, so ShareBetaBloc's // handlers were never examined. final owners = { for (final finding in findings) if (finding.rule == ruleDeadHandler && finding.data['event'] == 'ShareExpired') finding.data['owner'], }; expect(owners, {'ShareBetaBloc'}); }); test('rendered-not-emitted: redirect resolution does not mute the rule', () { // DocStale is rendered and never emitted, so crediting factory calls to // their redirect targets must leave it reported. expect(valuesOf(ruleRenderedNotEmitted, 'variant'), contains('DocStale')); }); test( 'riverpod-async-gap: a value stored in a variable is still followed', () { // The parent climb stopped at the variable declaration, so a stored value // read with `.value` was invisible to the rule. expect( valuesOf(ruleRiverpodAsyncGap, 'provider'), contains('storedUnhandledProvider'), ); }, ); test('event-without-handler: a group handler does not cover a sibling', () { // UploadAbandoned sits outside the handled UploadRetry group, so following // the hierarchy must not over-credit it. expect( valuesOf(ruleEventWithoutHandler, 'event'), contains('UploadAbandoned'), ); }); test('loading-flag-not-reset: two handlers in one class stay distinct', () { // As `` they shared one fingerprint, so a baseline kept only one. final hits = findings .where( (finding) => finding.rule == ruleLoadingFlagNotReset && finding.data['owner'] == 'FlagCubit', ) .toList(); expect( hits.map((finding) => finding.data['member']), containsAll(['on', 'on']), ); expect( hits.map((finding) => finding.fingerprint).toSet(), hasLength(hits.length), ); }); for (final entry in _positives.entries) { test('${entry.key}: fires on the positive control', () { final (key, value) = entry.value; expect( valuesOf(entry.key, key), contains(value), reason: 'rule ${entry.key} did not fire for $key=$value', ); }); } for (final entry in _negatives.entries) { test('${entry.key}: stays silent on the negative control', () { for (final (key, value) in entry.value) { expect( valuesOf(entry.key, key), isNot(contains(value)), reason: 'rule ${entry.key} wrongly fired for $key=$value', ); } }); } test('every rule has a positive and a negative control', () { for (final rule in allRules) { expect( _positives.containsKey(rule), isTrue, reason: '$rule has no positive control (recall is unproven)', ); expect( _negatives.containsKey(rule), isTrue, reason: '$rule has no negative control (precision is unguarded)', ); } // And the controls must not reference rules that no longer exist. for (final rule in _positives.keys) { expect(allRules, contains(rule)); } }); test('a union no UI observes collapses to ONE decision, not N bugs', () { // gaps_ui.dart renders GapState but never ItemState, so every emitted // ItemState variant is unreachable. That is one product decision, and // reporting three separate findings for it buries the real regressions. final hits = findings .where( (finding) => finding.rule == ruleEmittedNotRendered && finding.data['union'] == 'ItemState', ) .toList(); expect(hits, hasLength(1)); final only = hits.single; expect(only.data['dormant'], isTrue); expect(only.data['variantCount'], greaterThan(1)); expect(only.data['variants'], isA>()); expect( only.data.containsKey('variant'), isFalse, reason: 'a dormant finding keys on the union, not on one variant', ); expect(only.confidence, 'high'); // The default policy leads with wiring the feature up. expect(only.remediation, startsWith('Wire it up')); }); test('read-in-build fires only for Listenables inside a build method', () { final hits = findings .where((finding) => finding.rule == ruleProviderReadInBuild) .toList(); // Exactly two: context.read and Provider.of(listen: false), both in build. // context.watch in build subscribes, reading an immutable service is fine, // and a read in a callback is correct, so none of those may be reported. expect(hits.map((f) => f.data['call']).toList()..sort(), [ 'Provider.of(context, listen: false)', 'context.read()', ]); expect(hits.map((f) => f.data['valueType']), everyElement('LiveNotifier')); expect(hits.first.confidence, 'medium'); expect(hits.first.remediation, contains('context.watch')); }); test('field-unread lists only members nothing reads', () { final hits = findings .where((finding) => finding.rule == ruleNotifierFieldUnread) .toList(); expect(hits, hasLength(1)); expect(hits.single.data['owner'], 'PartiallyReadNotifier'); expect(hits.single.data['members'], ['unusedFlag']); // Reported once per notifier, not once per unread member. expect(hits.single.data['memberCount'], 1); expect(hits.single.confidence, 'medium'); }); test('created-in-build fires once per build method, ignoring `.value`', () { final hits = findings .where((f) => f.rule == ruleProviderCreatedInBuild) .toList(); // Only CreatesProviderView counts, and it is reported once for the build // method rather than once per provider. The two `.value` cases pass an // instance created once, so they are correct. expect(hits, hasLength(1)); expect(hits.single.data['owner'], 'CreatesProviderView'); expect(hits.single.data['member'], 'build'); expect(hits.single.data['providerCount'], 1); expect(hits.single.data['firstKind'], 'ChangeNotifierProvider'); expect(hits.single.data['valueTypes'], ['LiveNotifier']); expect(hits.single.remediation, contains('.value')); }); test('a variant name shared with another union is not cross-credited', () { // The fixture declares DuplicatedName twice, in two files with different // unions, and only AlphaState's is rendered. Unqualified-name matching // without per-union scoping would mark the Beta variant rendered too and // silently drop this finding. final hits = findings .where( (finding) => finding.rule == ruleEmittedNotRendered && (finding.data['variants'] as List?)?.contains('DuplicatedName') == true, ) .toList(); expect(hits, hasLength(1)); expect(hits.single.data['union'], 'BetaState'); }); test('every reported rule in the contract corpus is covered', () { final reported = findings.map((finding) => finding.rule).toSet(); final uncovered = reported.difference(_positives.keys.toSet()); expect( uncovered, isEmpty, reason: 'these rules fired without a contract: $uncovered', ); }); }