pipe/test/model_cli_test.dart
trvon 0d891919e9
feat(check): run the rules on a state model written by any front end
Third step of the state-map seam, and the proof that it holds.

stateFlowFromJson rebuilds a StateFlow from its toJson form (computed
members - totals, coverage differences, Riverpod gaps - are recomputed),
and `pipe check --model <file>` runs every rule on such a file instead
of analysing Dart. A front end for another language now only has to
write that JSON to get every rule, --baseline ratcheting and --fail-on
gating. StateUnion.toJson now carries uiReadsBaseFields (only when
true), which the round trip showed it had been dropping.

Checked byte for byte, through a file on disk: `pipe state --format
json` then `pipe check --model` reports exactly what `pipe check` does
on the fixture and on resolved mirrors of nuwayze (102 findings), tick,
dex and word_alive. state_map_roundtrip_test.dart asserts the same in
process (model JSON is idempotent; findings identical; every rule fires,
so the comparison is not vacuous), and model_cli_test.dart covers the
CLI path.

--model refuses --write-baseline: a model does not record whether its
front end read every file, so the baseline could silently be partial -
the hazard the source path already refuses. A malformed model is a
PipeException, not a crash.

134 tests pass.
2026-09-22 14:31:03 -06:00

116 lines
3 KiB
Dart

import 'dart:io';
import 'package:pipe/pipe.dart';
import 'package:pipe/src/cli.dart';
import 'package:test/test.dart';
/// Collects what a command writes to stdout.
class _CapturedStdout implements Stdout {
final StringBuffer buffer = StringBuffer();
@override
void write(Object? object) => buffer.write(object);
@override
void writeln([Object? object = '']) => buffer.writeln(object);
@override
dynamic noSuchMethod(Invocation invocation) => null;
}
Future<(int?, String)> _run(List<String> args) async {
final out = _CapturedStdout();
final code = await IOOverrides.runZoned(
() => PipeRunner().run(args),
stdout: () => out,
);
return (code, out.buffer.toString());
}
const String _fixture = 'test/fixtures/stuck_loading';
void main() {
late String model;
setUpAll(() async {
final dir = Directory.systemTemp.createTempSync('pipe-model');
model = '${dir.path}/model.json';
final (code, json) = await _run([
'state',
_fixture,
'--format',
'json',
'-c',
'1',
]);
expect(code, 0);
File(model).writeAsStringSync(json);
});
test('check --model reports exactly what a source check reports', () async {
// The front-end seam as a separate process would use it: the model goes
// through a file, and the rules must not notice.
final (_, fromSource) = await _run([
'check',
_fixture,
'--format',
'json',
'-c',
'1',
]);
final (_, fromModel) = await _run([
'check',
'--model',
model,
'--format',
'json',
]);
expect(fromModel, fromSource);
});
test('check --model honours --rule and --fail-on', () async {
final (code, output) = await _run([
'check',
'--model',
model,
'--rule',
ruleEventWithoutHandler,
'--fail-on',
'error',
'--format',
'json',
]);
expect(code, 1, reason: 'event-without-handler fires at error severity');
expect(output, contains(ruleEventWithoutHandler));
expect(output, isNot(contains(ruleDeadHandler)));
});
test('check --model refuses --write-baseline', () async {
final target = '${File(model).parent.path}/baseline.json';
await expectLater(
_run(['check', '--model', model, '--write-baseline', target]),
throwsA(
isA<PipeException>().having(
(error) => error.toString(),
'message',
contains('--model'),
),
),
);
expect(File(target).existsSync(), isFalse);
});
test('check --model rejects a file that is not a model', () async {
final dir = File(model).parent.path;
File('$dir/array.json').writeAsStringSync('[1, 2]');
// An object missing required fields must be a clear error, not a crash.
File('$dir/partial.json').writeAsStringSync('{"unions": [{"name": 1}]}');
for (final bad in ['array.json', 'partial.json']) {
await expectLater(
_run(['check', '--model', '$dir/$bad']),
throwsA(isA<PipeException>()),
reason: bad,
);
}
});
}