add LogRing — bounded retention for Logger records (T-54)

The Logger only live-broadcasts; an output panel opening late would see no
history. LogRing is a drop-oldest sink (default 2000 records) that keeps the
recent window plus per-source and per-level bookkeeping — distinct sources
for the panel's filter dropdown, level counts for the status-bar health
badge. Flutter-free; the dock UI wraps it. First slice of the D-87 dock.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 23:10:07 +02:00
co-authored by Claude Opus 4.8
parent da61af810d
commit 38058c17f5
4 changed files with 203 additions and 0 deletions
+94
View File
@@ -0,0 +1,94 @@
/// T-54: LogRing — bounded drop-oldest retention for Logger records, with
/// per-source / per-level bookkeeping for the panel filter + status badge.
library;
import 'package:clide/kernel/src/log.dart';
import 'package:clide/kernel/src/log_ring.dart';
import 'package:flutter_test/flutter_test.dart';
LogRecord _rec(LogLevel level, String source, String message) =>
LogRecord(level: level, source: source, message: message, timestamp: DateTime.utc(2026, 6, 6));
void main() {
test('retains records in arrival order', () {
final ring = LogRing(capacity: 10);
ring
..add(_rec(LogLevel.info, 'ipc', 'a'))
..add(_rec(LogLevel.info, 'mcp', 'b'));
expect(ring.records.map((r) => r.message), ['a', 'b']);
expect(ring.length, 2);
});
test('drops oldest past capacity', () {
final ring = LogRing(capacity: 3);
for (final m in ['a', 'b', 'c', 'd', 'e']) {
ring.add(_rec(LogLevel.info, 'ipc', m));
}
expect(ring.records.map((r) => r.message), ['c', 'd', 'e']);
});
test('sources are distinct, sorted, and shrink as records age out', () {
final ring = LogRing(capacity: 2);
ring
..add(_rec(LogLevel.info, 'git', '1'))
..add(_rec(LogLevel.info, 'ipc', '2'));
expect(ring.sources, ['git', 'ipc']);
// Pushing past capacity evicts the 'git' record → 'git' leaves the set.
ring.add(_rec(LogLevel.info, 'pql', '3'));
expect(ring.sources, ['ipc', 'pql']);
});
test('countAtLeast totals retained records at a level or higher', () {
final ring = LogRing(capacity: 10);
ring
..add(_rec(LogLevel.debug, 'x', 'd'))
..add(_rec(LogLevel.info, 'x', 'i'))
..add(_rec(LogLevel.warn, 'x', 'w'))
..add(_rec(LogLevel.error, 'x', 'e'));
expect(ring.countAtLeast(LogLevel.warn), 2); // warn + error
expect(ring.countAtLeast(LogLevel.error), 1);
expect(ring.countAtLeast(LogLevel.trace), 4);
});
test('eviction decrements level counts', () {
final ring = LogRing(capacity: 2);
ring
..add(_rec(LogLevel.error, 'x', 'e1'))
..add(_rec(LogLevel.info, 'x', 'i'))
..add(_rec(LogLevel.info, 'x', 'i2')); // evicts the error
expect(ring.countAtLeast(LogLevel.error), 0);
});
test('clear empties records, sources, and counts', () {
final ring = LogRing(capacity: 10);
ring
..add(_rec(LogLevel.warn, 'pql', 'w'))
..clear();
expect(ring.isEmpty, isTrue);
expect(ring.sources, isEmpty);
expect(ring.countAtLeast(LogLevel.trace), 0);
});
test('changes fires on add and on clear', () async {
final ring = LogRing(capacity: 10);
final events = <void>[];
final sub = ring.changes.listen(events.add);
ring.add(_rec(LogLevel.info, 'x', 'a'));
ring.clear();
await Future<void>.delayed(Duration.zero);
expect(events.length, 2);
await sub.cancel();
ring.dispose();
});
test('clear on an empty ring does not fire changes', () async {
final ring = LogRing(capacity: 10);
final events = <void>[];
final sub = ring.changes.listen(events.add);
ring.clear();
await Future<void>.delayed(Duration.zero);
expect(events, isEmpty);
await sub.cancel();
ring.dispose();
});
}