Files
clide/test/builtin/claude/team_chat_model_test.dart
T
jpmschweitzerandClaude Opus 4.8 e55b4a9173 test: deflake the suite — pumpEventQueue instead of Future.delayed(Duration.zero)
~173 `await Future<void>.delayed(Duration.zero)` async-settle waits across 25
test files yield the microtask queue exactly once; when an event→handler chain
needs more than one hop they lose the race under CI's parallel load, so the
failing set varied run to run. Replace with `await pumpEventQueue()` (the
deterministic drain already used elsewhere in the suite); rewired the shared
settle()/tick() helpers in one shot. menu_bar's toggle-close test gets a bounded
extra pump. Verified green under CI=true.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 23:13:13 +02:00

200 lines
7.0 KiB
Dart

/// Unit tests for TeamChatModel (T-180).
///
/// All tests are pure Dart (no Flutter widgets). TeamChatModel and TeamBroker
/// are both Flutter-free; this file runs under `flutter test` but does NOT
/// import any Flutter package.
library;
import 'package:clide/builtin/claude/src/team_broker.dart';
import 'package:clide/builtin/claude/src/team_chat_model.dart';
import 'package:test/test.dart';
void main() {
late TeamBroker broker;
late List<(String, String)> delivered; // (toMemberId, text)
late TeamChatModel model;
setUp(() {
delivered = [];
broker = TeamBroker(deliver: (to, text) => delivered.add((to, text)));
broker.addMember(const TeamMemberRef(id: 'primary', name: 'lead', role: 'lead'));
broker.addMember(const TeamMemberRef(id: 'teammate:tyre', name: 'tyre', role: 'teammate'));
model = TeamChatModel(broker: broker);
});
tearDown(() {
model.dispose();
broker.dispose();
});
// ---------------------------------------------------------------------------
// Message stream → timeline
// ---------------------------------------------------------------------------
group('message stream', () {
test('model starts with an empty timeline', () {
expect(model.messages, isEmpty);
});
test('appends a message when the broker delivers one', () async {
final events = <void>[];
final sub = model.changes.listen((_) => events.add(null));
broker.sendMessage('primary', 'tyre', 'hello tyre');
await pumpEventQueue();
await sub.cancel();
expect(model.messages, hasLength(1));
expect(model.messages.single.from, 'lead');
expect(model.messages.single.text, 'hello tyre');
expect(model.messages.single.to, 'tyre');
expect(events, hasLength(1));
});
test('broadcast messages are appended for each recipient', () async {
broker.broadcast('primary', 'standup');
await pumpEventQueue();
// One message for 'tyre', one for 'user' (both are non-sender members).
expect(model.messages.length, greaterThanOrEqualTo(1));
expect(model.messages.every((m) => m.text == 'standup'), isTrue);
});
test('direct send_message to user lands in the timeline', () async {
broker.sendMessage('primary', 'user', 'attention user');
await pumpEventQueue();
expect(model.messages.single.text, 'attention user');
expect(model.messages.single.to, 'user');
// User has no stdin delivery.
expect(delivered, isEmpty);
});
test('changes stream fires on each message', () async {
final events = <void>[];
final sub = model.changes.listen((_) => events.add(null));
broker.sendMessage('primary', 'tyre', 'one');
broker.sendMessage('primary', 'tyre', 'two');
await pumpEventQueue();
await sub.cancel();
expect(events, hasLength(2));
});
test('messages list is append-only (oldest first)', () async {
broker.sendMessage('primary', 'tyre', 'first');
broker.sendMessage('primary', 'tyre', 'second');
await pumpEventQueue();
expect(model.messages[0].text, 'first');
expect(model.messages[1].text, 'second');
});
});
// ---------------------------------------------------------------------------
// postAsUser — routing
// ---------------------------------------------------------------------------
group('postAsUser routing', () {
test('postAsUser with no toName broadcasts to all agents', () async {
model.postAsUser('hello team');
await pumpEventQueue();
// Delivered to tyre (lead is the sender-equivalent; user has no delivery).
expect(delivered.any((d) => d.$1 == 'teammate:tyre'), isTrue);
});
test('postAsUser with toName=team broadcasts', () async {
model.postAsUser('standup', toName: 'team');
await pumpEventQueue();
expect(delivered.any((d) => d.$1 == 'teammate:tyre'), isTrue);
});
test('postAsUser with a member name delivers to that member only', () async {
model.postAsUser('hey tyre', toName: 'tyre');
await pumpEventQueue();
expect(delivered.length, 1);
expect(delivered.single.$1, 'teammate:tyre');
});
test('postAsUser creates a local timeline entry immediately', () {
model.postAsUser('quick post', toName: 'tyre');
// Synchronous: the entry is in messages before any async event.
expect(model.messages, hasLength(1));
expect(model.messages.single.from, 'user');
expect(model.messages.single.to, 'tyre');
});
test('postAsUser broadcast creates a local entry with broadcast=true', () {
model.postAsUser('broadcast text');
expect(model.messages.single.broadcast, isTrue);
});
test('postAsUser fires the changes stream synchronously', () {
var fired = false;
model.changes.listen((_) => fired = true);
model.postAsUser('sync');
// The stream is broadcast but the listener is called asynchronously by
// the Dart event loop — wait one microtask.
expect(fired, isFalse); // not yet
});
});
// ---------------------------------------------------------------------------
// postAsUser — interrupt flag
// ---------------------------------------------------------------------------
group('postAsUser interrupt', () {
test('interrupt=true calls the session resolver', () async {
String? resolvedName;
final interruptModel = TeamChatModel(
broker: broker,
sessionResolver: (name) {
resolvedName = name;
return null; // no real session in unit tests
},
);
interruptModel.postAsUser('cancel that', toName: 'tyre', interrupt: true);
await pumpEventQueue();
expect(resolvedName, 'tyre');
interruptModel.dispose();
});
test('interrupt=true on a broadcast does NOT call the resolver', () async {
String? resolvedName;
final interruptModel = TeamChatModel(
broker: broker,
sessionResolver: (name) {
resolvedName = name;
return null;
},
);
interruptModel.postAsUser('abort all', interrupt: true);
await pumpEventQueue();
// Broadcast → resolver not called (no single target to interrupt).
expect(resolvedName, isNull);
interruptModel.dispose();
});
test('interrupt=false never calls the resolver', () async {
String? resolvedName;
final interruptModel = TeamChatModel(
broker: broker,
sessionResolver: (name) {
resolvedName = name;
return null;
},
);
interruptModel.postAsUser('no interrupt', toName: 'tyre', interrupt: false);
await pumpEventQueue();
expect(resolvedName, isNull);
interruptModel.dispose();
});
});
// ---------------------------------------------------------------------------
// Dispose
// ---------------------------------------------------------------------------
test('dispose closes the changes stream', () async {
var done = false;
model.changes.listen(null, onDone: () => done = true);
model.dispose();
await pumpEventQueue();
expect(done, isTrue);
});
}