Files
ux/test/log_test.dart
agra 6f73b53c5e 0.8.0: pretty logger (Log) + crash capture
- Log: static entry + scoped Loggers (Log.tag), six levels, lazy messages,
  structured fields, ANSI ConsoleSink, DeveloperSink, MemorySink, NoopSink.
  Sinks compose via +; throwing sinks are isolated.
- Log.configure wires FlutterError/PlatformDispatcher/isolate errors through
  Log.e by default; log-then-rethrow deduped via Expando.
- UxKeyboard: migrate kDebugMode print() to Log.tag('KB').d lazily.
- Depend on package:clock for testable timestamps.
2026-04-24 15:07:06 +03:00

290 lines
8.5 KiB
Dart

import 'package:clock/clock.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ux/ux.dart';
void _noop() {}
MemorySink _installMemorySink({LogLevel minLevel = LogLevel.trace}) {
final sink = MemorySink();
Log.configure(minLevel: minLevel, sink: sink, captureCrashes: _noop);
return sink;
}
void main() {
group('LogLevel', () {
test('numeric values align with package:logging', () {
expect(LogLevel.trace.value, 500);
expect(LogLevel.debug.value, 700);
expect(LogLevel.info.value, 800);
expect(LogLevel.warn.value, 900);
expect(LogLevel.error.value, 1000);
expect(LogLevel.fatal.value, 1200);
});
test('letters are distinct and uppercase', () {
final letters = LogLevel.values.map((l) => l.letter).toSet();
expect(letters.length, LogLevel.values.length);
for (final l in letters) {
expect(l, l.toUpperCase());
}
});
});
group('MemorySink', () {
test('records events in order', () {
final sink = _installMemorySink();
Log.d('one');
Log.i('two');
Log.w('three');
expect(sink.snapshot().map((r) => r.message).toList(), [
'one',
'two',
'three',
]);
expect(sink.snapshot().map((r) => r.level).toList(), [
LogLevel.debug,
LogLevel.info,
LogLevel.warn,
]);
});
test('evicts oldest past capacity', () {
final sink = MemorySink(capacity: 3);
Log.configure(minLevel: LogLevel.trace, sink: sink);
for (var i = 0; i < 5; i++) {
Log.i('msg $i');
}
expect(sink.snapshot().map((r) => r.message).toList(), [
'msg 2',
'msg 3',
'msg 4',
]);
});
test('snapshot is unmodifiable', () {
final sink = _installMemorySink();
Log.i('x');
expect(() => sink.snapshot().clear(), throwsUnsupportedError);
});
test('clear empties the buffer', () {
final sink = _installMemorySink();
Log.i('x');
sink.clear();
expect(sink.snapshot(), isEmpty);
});
});
group('level filtering', () {
test('records below Log.minLevel are dropped', () {
final sink = _installMemorySink(minLevel: LogLevel.warn);
Log.t('trace');
Log.d('debug');
Log.i('info');
Log.w('warn');
Log.e('error');
expect(sink.snapshot().map((r) => r.level).toList(), [
LogLevel.warn,
LogLevel.error,
]);
});
test('per-sink minLevel filters independently', () {
final verbose = MemorySink();
final errorsOnly = MemorySink(minLevel: LogLevel.error);
Log.configure(minLevel: LogLevel.trace, sink: verbose + errorsOnly);
Log.i('info');
Log.e('error');
expect(verbose.snapshot().map((r) => r.message), ['info', 'error']);
expect(errorsOnly.snapshot().map((r) => r.message), ['error']);
});
});
group('lazy messages', () {
test('thunk is not invoked when filtered', () {
_installMemorySink(minLevel: LogLevel.warn);
var called = 0;
Log.d(() {
called++;
return 'expensive';
});
expect(called, 0);
});
test('thunk is invoked and rendered when enabled', () {
final sink = _installMemorySink();
var called = 0;
Log.d(() {
called++;
return 'computed';
});
expect(called, 1);
expect(sink.snapshot().single.message, 'computed');
});
});
group('tagging', () {
test('Log.tag sets the record tag', () {
final sink = _installMemorySink();
Log.tag('KB').i('hello');
expect(sink.snapshot().single.tag, 'KB');
});
test('root logger emits with null tag', () {
final sink = _installMemorySink();
Log.i('rootless');
expect(sink.snapshot().single.tag, isNull);
});
test('nested tag joins with a dot', () {
final sink = _installMemorySink();
Log.tag('A').tag('B').i('x');
expect(sink.snapshot().single.tag, 'A.B');
});
});
group('errors and stack traces', () {
test('error and stack pass through to the record', () {
final sink = _installMemorySink();
final err = Exception('boom');
final st = StackTrace.current;
Log.e('fail', error: err, stackTrace: st);
final r = sink.snapshot().single;
expect(r.error, same(err));
expect(r.stackTrace, same(st));
});
test('fields pass through', () {
final sink = _installMemorySink();
Log.i('hit', fields: {'userId': 42, 'path': '/x'});
expect(sink.snapshot().single.fields, {'userId': 42, 'path': '/x'});
});
});
group('dedupe via crash handler', () {
test('FlutterError.onError skips an error already seen by Log.e', () {
final prevFlutter = FlutterError.onError;
addTearDown(() => FlutterError.onError = prevFlutter);
final sink = MemorySink();
Log.configure(minLevel: LogLevel.trace, sink: sink);
final err = Exception('once');
Log.e('caught', error: err);
FlutterError.onError!(FlutterErrorDetails(exception: err));
expect(sink.snapshot().map((r) => r.message), ['caught']);
});
test('FlutterError.onError still reports a fresh error', () {
final prevFlutter = FlutterError.onError;
addTearDown(() => FlutterError.onError = prevFlutter);
final sink = MemorySink();
Log.configure(minLevel: LogLevel.trace, sink: sink);
FlutterError.onError!(FlutterErrorDetails(exception: Exception('new')));
expect(sink.snapshot().single.level, LogLevel.error);
expect(sink.snapshot().single.error, isA<Exception>());
});
});
group('clock', () {
test('record time comes from package:clock', () {
final sink = _installMemorySink();
final fixed = DateTime.utc(2026, 1, 2, 3, 4, 5, 678);
withClock(Clock.fixed(fixed), () {
Log.i('pinned');
});
expect(sink.snapshot().single.time, fixed);
});
});
group('composite sink', () {
test('chained + flattens into a single composite', () {
final a = MemorySink();
final b = MemorySink();
final c = MemorySink();
Log.configure(minLevel: LogLevel.trace, sink: a + b + c);
Log.i('fan');
expect(a.snapshot().single.message, 'fan');
expect(b.snapshot().single.message, 'fan');
expect(c.snapshot().single.message, 'fan');
});
test('a throwing sink does not stop others', () {
final recorded = MemorySink();
final bomb = _ThrowingSink();
Log.configure(minLevel: LogLevel.trace, sink: bomb + recorded);
Log.i('survive');
expect(recorded.snapshot().single.message, 'survive');
});
});
group('ConsoleSink formatting', () {
test('writes a single line with level letter and message', () {
final lines = <String>[];
Log.configure(
minLevel: LogLevel.trace,
sink: ConsoleSink(color: false, write: lines.add),
);
withClock(Clock.fixed(DateTime.utc(2026, 1, 1, 12, 34, 56, 789)), () {
Log.tag('T').i('hello');
});
expect(lines.single, contains('12:34:56.789'));
expect(lines.single, contains(' I '));
expect(lines.single, contains('T hello'));
});
test('renders fields inline', () {
final lines = <String>[];
Log.configure(
minLevel: LogLevel.trace,
sink: ConsoleSink(color: false, write: lines.add),
);
Log.i('hit', fields: {'n': 3});
expect(lines.single, contains('{n: 3}'));
});
test('renders error on a follow-up line', () {
final lines = <String>[];
Log.configure(
minLevel: LogLevel.trace,
sink: ConsoleSink(color: false, write: lines.add),
);
Log.e('oops', error: Exception('bad'));
expect(lines.single.split('\n').length, greaterThan(1));
expect(lines.single, contains('└─ Exception: bad'));
});
test('caps stack frames', () {
final lines = <String>[];
Log.configure(
minLevel: LogLevel.trace,
sink: ConsoleSink(color: false, stackFrames: 2, write: lines.add),
);
final longStack = StackTrace.fromString(
List.generate(10, (i) => '#$i frame$i').join('\n'),
);
Log.e('x', error: Exception('e'), stackTrace: longStack);
final stackLines = lines.single
.split('\n')
.where((l) => l.contains('#'))
.toList();
expect(stackLines.length, 2);
});
});
}
class _ThrowingSink extends LogSink {
@override
LogLevel get minLevel => LogLevel.trace;
@override
void emit(LogRecord record) => throw StateError('no');
}