pipe/test/state_flow_test.dart
trvon c3f1fb6319
refactor(rules): provider rules judge facts; runChecks takes only the model
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.
2026-09-22 14:23:39 -06:00

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);
}
});
}