Second step of the state-map seam. The five ChangeNotifier/provider rules walked resolved units from runProviderRules. Their recognisers move to the Dart front end (dart_provider_facts.dart) and emit a NotifierModel of neutral facts (facts.dart): - NotifierFact / NotifierMethod / FieldMutation: an observable object that must announce its own changes, its methods, what each calls, mutates, and whether it notifies directly. Deliberately general enough for a Qt QObject or a hand-rolled observer subject. - consumer sites, provider declarations, member uses per file. - NonSubscribingRead: every read made while building without subscribing; whether the type is observable is now the rule's filter, not the recogniser's. - ProvidersCreatedInBuild: providers constructed in a build method. The transitive notify closure, the observable/lifecycle/wrapper judgements and all messages stay in provider_rules.dart, which no longer imports the analyzer. runChecks now takes only a StateFlow: no Project, no FileOutcome. The scanned directories ride on the model (StateFlow.directories) for the dead-handler scope claim. facts.dart is exported publicly. No behaviour change, checked byte for byte: `check --format json` is identical on the fixture and on resolved mirrors of nuwayze, tick, dex and word_alive. 128 tests pass.
123 lines
4.6 KiB
Dart
123 lines
4.6 KiB
Dart
import 'package:pipe/pipe.dart';
|
|
import 'package:test/test.dart';
|
|
|
|
const String fixturePath = 'test/fixtures/stuck_loading';
|
|
|
|
void main() {
|
|
late Project project;
|
|
late ResolutionRun run;
|
|
late StateFlow flow;
|
|
late FindingReport report;
|
|
|
|
setUpAll(() async {
|
|
project = await loadProject(fixturePath);
|
|
run = await resolveProject(project, concurrency: 1);
|
|
flow = buildStateFlow(project, run.outcomes);
|
|
report = runChecks(flow: flow);
|
|
});
|
|
|
|
tearDownAll(() async {
|
|
await run.dispose();
|
|
});
|
|
|
|
test('fixture resolves with zero analyzer errors', () {
|
|
final failures = run.outcomes.where((o) => !o.resolved).toList();
|
|
expect(
|
|
failures,
|
|
isEmpty,
|
|
reason: failures.map((f) => '${f.path}: ${f.failure}').join('\n'),
|
|
);
|
|
|
|
// Resolution succeeding is not the same as the fixture being valid: a unit
|
|
// can resolve while containing compile errors, which would silently distort
|
|
// the IR every rule test depends on. `dart analyze` cannot catch this because
|
|
// test/fixtures/** is excluded from the repo's analysis_options, so it is
|
|
// asserted here from the analyzer's own diagnostics.
|
|
final errors = <String>[];
|
|
for (final outcome in run.outcomes) {
|
|
for (final diagnostic in outcome.diagnostics) {
|
|
if (diagnostic.severity.name != 'error') continue;
|
|
errors.add(
|
|
'${outcome.path}:${outcome.unit?.lineInfo.getLocation(diagnostic.offset).lineNumber} '
|
|
'${diagnostic.diagnosticCode.lowerCaseName}: ${diagnostic.message}',
|
|
);
|
|
}
|
|
}
|
|
expect(errors, isEmpty, reason: errors.join('\n'));
|
|
});
|
|
|
|
test('builds the state union and its variants', () {
|
|
final union = flow.unions.firstWhere((u) => u.name == 'ItemState');
|
|
expect(union.owner, 'ItemBloc');
|
|
expect(
|
|
union.variants.map((v) => v.name),
|
|
containsAll(<String>['ItemLoading', 'ItemLoaded', 'ItemError']),
|
|
);
|
|
expect(union.style, UnionStyle.abstract);
|
|
});
|
|
|
|
test('loading-not-reset fires on the swallowed loading path', () {
|
|
final hits = report.findings
|
|
.where((finding) => finding.rule == ruleLoadingNotReset)
|
|
.toList();
|
|
expect(hits, isNotEmpty);
|
|
// Every firing site is in the fixture's bad handlers/catches, and all of
|
|
// them are high confidence warnings.
|
|
expect(
|
|
hits.every((f) => f.location.contains('item_bloc.dart')),
|
|
isTrue,
|
|
reason: 'unexpected firing site: ${hits.map((f) => f.location).toList()}',
|
|
);
|
|
expect(hits.every((f) => f.confidence == 'high'), isTrue);
|
|
expect(hits.every((f) => f.severity == PipeSeverity.warning), isTrue);
|
|
});
|
|
|
|
test('does not fire where the catch resolves the loading state', () {
|
|
// GuardedCubit emits a loading variant and awaits inside a try whose catch
|
|
// emits a terminal state, so reporting it would be a false positive. This is
|
|
// the precise form of the old "only one finding" assertion, which stopped
|
|
// proving anything once the fixture gained a second bad site.
|
|
final owners = report.findings
|
|
.where((f) => f.rule == ruleLoadingNotReset)
|
|
.map((f) => f.data['owner'])
|
|
.toSet();
|
|
expect(owners, isNot(contains('GuardedCubit')));
|
|
expect(owners, contains('ItemBloc'));
|
|
});
|
|
|
|
test('emitted-not-rendered reports variants no UI consumes', () {
|
|
final hits = report.findings
|
|
.where((finding) => finding.rule == ruleEmittedNotRendered)
|
|
.toList();
|
|
expect(hits, isNotEmpty);
|
|
expect(hits.map((f) => f.data['union']).toSet(), contains('ItemState'));
|
|
// ItemState has no UI observing it, so those findings are high confidence.
|
|
final itemState = hits.where(
|
|
(finding) => finding.data['union'] == 'ItemState',
|
|
);
|
|
expect(itemState, isNotEmpty);
|
|
expect(itemState.every((f) => f.confidence == 'high'), isTrue);
|
|
});
|
|
|
|
test('event-without-handler catches a dispatch with no registration', () {
|
|
final hits = report.findings
|
|
.where((finding) => finding.rule == ruleEventWithoutHandler)
|
|
.toList();
|
|
// ItemBloc registers handlers for both of its events, and SyncBloc/UploadBloc
|
|
// cover their other events through base and group handlers, so the only
|
|
// reports must be the two deliberate gaps.
|
|
expect(
|
|
{for (final hit in hits) '${hit.data['owner']}.${hit.data['event']}'},
|
|
{'GapBloc.UnhandledTap', 'UploadBloc.UploadAbandoned'},
|
|
);
|
|
expect(hits, hasLength(2));
|
|
});
|
|
|
|
test('every finding carries evidence', () {
|
|
for (final finding in report.findings) {
|
|
expect(finding.evidence, isNotEmpty, reason: finding.message);
|
|
expect(finding.remediation, isNotNull, reason: finding.message);
|
|
expect(finding.file, isNotNull, reason: finding.message);
|
|
}
|
|
});
|
|
}
|