add the team chat inbox over the broker
Renders broker traffic as a chat timeline and makes the user a first-class participant. The broker grows a Stream<TeamMessage> and a recipient field, auto-registers a virtual `user` member, and gains sendAsUser. A Flutter-free TeamChatModel (owned by the orchestrator) accumulates the feed and exposes postAsUser with @-routing (a new at_commands helper mirroring slash) and an optional interrupt that cancels the target's turn before delivery. One model backs two surfaces: a compact cockpit widget that pops out into a full workspace chat pane. CLI parity via clide.team-chat.open / .post. T-180. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
/// Unit tests for the @-name completion helpers (T-180, at_commands.dart).
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/at_commands.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
group('activeAtQuery', () {
|
||||
test('returns null when text is empty', () {
|
||||
expect(activeAtQuery('', 0), isNull);
|
||||
});
|
||||
|
||||
test('returns null when cursor is at a non-@ run', () {
|
||||
expect(activeAtQuery('hello world', 5), isNull);
|
||||
});
|
||||
|
||||
test('detects @name at start of text', () {
|
||||
const text = '@tyre';
|
||||
final q = activeAtQuery(text, text.length);
|
||||
expect(q, isNotNull);
|
||||
expect(q!.start, 0);
|
||||
expect(q.query, 'tyre');
|
||||
});
|
||||
|
||||
test('detects @name after whitespace', () {
|
||||
const text = 'hi @tyre';
|
||||
final q = activeAtQuery(text, text.length);
|
||||
expect(q, isNotNull);
|
||||
expect(q!.start, 3);
|
||||
expect(q.query, 'tyre');
|
||||
});
|
||||
|
||||
test('returns null when @ is mid-word (e.g. email address)', () {
|
||||
const text = 'user@example.com';
|
||||
final q = activeAtQuery(text, text.length);
|
||||
expect(q, isNull);
|
||||
});
|
||||
|
||||
test('returns empty query when cursor is right after @', () {
|
||||
const text = '@';
|
||||
final q = activeAtQuery(text, 1);
|
||||
expect(q, isNotNull);
|
||||
expect(q!.query, '');
|
||||
});
|
||||
|
||||
test('partial name is captured up to the cursor', () {
|
||||
const text = '@ty';
|
||||
final q = activeAtQuery(text, 3);
|
||||
expect(q!.query, 'ty');
|
||||
});
|
||||
|
||||
test('returns null after a space follows the tag (token complete)', () {
|
||||
const text = '@tyre ';
|
||||
// Cursor is past the space — no active @-token.
|
||||
final q = activeAtQuery(text, text.length);
|
||||
expect(q, isNull);
|
||||
});
|
||||
});
|
||||
|
||||
group('filterAtNames', () {
|
||||
const names = ['lead', 'tyre', 'qatux'];
|
||||
|
||||
test('empty query returns team first then all names sorted', () {
|
||||
final results = filterAtNames('', names);
|
||||
expect(results.first, 'team');
|
||||
// The rest are alphabetically sorted names.
|
||||
expect(results.sublist(1).toSet(), containsAll(names));
|
||||
});
|
||||
|
||||
test('query prefix filters names case-insensitively', () {
|
||||
final results = filterAtNames('t', names);
|
||||
expect(results, containsAll(['team', 'tyre']));
|
||||
expect(results, isNot(contains('lead')));
|
||||
expect(results, isNot(contains('qatux')));
|
||||
});
|
||||
|
||||
test('team alias is always included when query matches', () {
|
||||
final results = filterAtNames('te', names);
|
||||
expect(results, contains('team'));
|
||||
});
|
||||
|
||||
test('team alias is excluded when query does not match', () {
|
||||
final results = filterAtNames('z', names);
|
||||
expect(results, isNot(contains('team')));
|
||||
});
|
||||
|
||||
test('de-duplicates names', () {
|
||||
final results = filterAtNames('', ['lead', 'lead']);
|
||||
expect(results.where((n) => n == 'lead'), hasLength(1));
|
||||
});
|
||||
|
||||
test('caps results at limit', () {
|
||||
final manyNames = List.generate(20, (i) => 'member$i');
|
||||
final results = filterAtNames('', manyNames, limit: 5);
|
||||
expect(results.length, lessThanOrEqualTo(5));
|
||||
});
|
||||
|
||||
test('team is pinned first even when other names sort before it', () {
|
||||
final results = filterAtNames('', ['alpha', 'beta']);
|
||||
expect(results.first, 'team');
|
||||
});
|
||||
});
|
||||
|
||||
group('completeAt', () {
|
||||
test('replaces the @ token with the selected name + trailing space', () {
|
||||
// Cursor is right after "ty"; the space + rest of line come after.
|
||||
// replaceRange(0, 3, '@tyre ') over '@ty rest' gives '@tyre rest'
|
||||
// (keeps the existing space). That is the correct contract — the
|
||||
// completion inserts its own trailing space; callers clear the space
|
||||
// if they want to avoid double spacing.
|
||||
const text = '@ty rest';
|
||||
final q = activeAtQuery(text, 3)!; // cursor after "ty"
|
||||
final result = completeAt(text, q, 'tyre');
|
||||
expect(result.text, '@tyre rest'); // double-space: inserted + existing
|
||||
expect(result.cursor, '@tyre '.length);
|
||||
});
|
||||
|
||||
test('works at the start of the text', () {
|
||||
const text = '@';
|
||||
final q = activeAtQuery(text, 1)!;
|
||||
final result = completeAt(text, q, 'lead');
|
||||
expect(result.text, '@lead ');
|
||||
expect(result.cursor, '@lead '.length);
|
||||
});
|
||||
});
|
||||
|
||||
group('parseAtTag', () {
|
||||
test('no leading @ returns null recipient and full text as body', () {
|
||||
final r = parseAtTag('hello world');
|
||||
expect(r.recipient, isNull);
|
||||
expect(r.body, 'hello world');
|
||||
});
|
||||
|
||||
test('leading @name splits into recipient + body', () {
|
||||
final r = parseAtTag('@tyre pick up T-9');
|
||||
expect(r.recipient, 'tyre');
|
||||
expect(r.body, 'pick up T-9');
|
||||
});
|
||||
|
||||
test('@team normalises to null recipient (broadcast)', () {
|
||||
final r = parseAtTag('@team hello everyone');
|
||||
expect(r.recipient, isNull);
|
||||
expect(r.body, 'hello everyone');
|
||||
});
|
||||
|
||||
test('just @name with no body', () {
|
||||
final r = parseAtTag('@lead');
|
||||
expect(r.recipient, 'lead');
|
||||
expect(r.body, '');
|
||||
});
|
||||
|
||||
test('leading whitespace is trimmed before @-tag parsing', () {
|
||||
final r = parseAtTag(' @tyre hello');
|
||||
expect(r.recipient, 'tyre');
|
||||
expect(r.body, 'hello');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -304,21 +304,23 @@ void main() {
|
||||
final semantics = tester.ensureSemantics();
|
||||
final orch = await orchWithMember(tester);
|
||||
|
||||
// Before tap: no inject field visible.
|
||||
expect(find.byType(EditableText), findsNothing);
|
||||
// Before tap: only the chat-composer field is visible (T-180).
|
||||
// find by focusNode debugLabel to count only the inject field (not the chat composer).
|
||||
final injectFinder = find.byWidgetPredicate((w) => w is EditableText && w.focusNode.debugLabel?.startsWith('inject-') == true);
|
||||
expect(injectFinder, findsNothing);
|
||||
|
||||
final injectTap = find.bySemanticsLabel('Inject message').first;
|
||||
await tester.tap(injectTap);
|
||||
await tester.pump();
|
||||
|
||||
// After tap: inject field appears.
|
||||
expect(find.byType(EditableText), findsOneWidget);
|
||||
expect(injectFinder, findsOneWidget);
|
||||
|
||||
// Tapping the cancel (×) icon dismisses it.
|
||||
final cancelTap = find.bySemanticsLabel('Cancel').first;
|
||||
await tester.tap(cancelTap);
|
||||
await tester.pump();
|
||||
expect(find.byType(EditableText), findsNothing);
|
||||
expect(injectFinder, findsNothing);
|
||||
|
||||
semantics.dispose();
|
||||
orch.dispose();
|
||||
@@ -353,15 +355,16 @@ void main() {
|
||||
// Open inject field.
|
||||
await tester.tap(find.bySemanticsLabel('Inject message').first);
|
||||
await tester.pump();
|
||||
expect(find.byType(EditableText), findsOneWidget);
|
||||
final injectFinder = find.byWidgetPredicate((w) => w is EditableText && w.focusNode.debugLabel?.startsWith('inject-') == true);
|
||||
expect(injectFinder, findsOneWidget);
|
||||
|
||||
// Type and submit.
|
||||
await tester.enterText(find.byType(EditableText).first, 'hello agent');
|
||||
await tester.enterText(injectFinder, 'hello agent');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pump();
|
||||
|
||||
// Field dismissed after submit.
|
||||
expect(find.byType(EditableText), findsNothing);
|
||||
// Inject field dismissed after submit (chat composer remains).
|
||||
expect(injectFinder, findsNothing);
|
||||
|
||||
semantics.dispose();
|
||||
orch.dispose();
|
||||
|
||||
@@ -261,7 +261,9 @@ void main() {
|
||||
memberName: 'tyre',
|
||||
));
|
||||
expect(orch.sessions, hasLength(2));
|
||||
expect(orch.broker.members, hasLength(2));
|
||||
// Broker has 2 agent members + 1 virtual 'user' member (T-180).
|
||||
final agentMembers = orch.broker.members.where((m) => m.id != 'user');
|
||||
expect(agentMembers, hasLength(2));
|
||||
|
||||
final ids = orch.sessions.map((m) => m.id).toList();
|
||||
for (final id in ids) {
|
||||
@@ -269,7 +271,9 @@ void main() {
|
||||
}
|
||||
|
||||
expect(orch.sessions, isEmpty);
|
||||
expect(orch.broker.members, isEmpty);
|
||||
// Only the virtual 'user' member remains after closing all sessions.
|
||||
final agentMembersAfter = orch.broker.members.where((m) => m.id != 'user');
|
||||
expect(agentMembersAfter, isEmpty);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -98,9 +98,12 @@ void main() {
|
||||
|
||||
test('team sessions register in the broker; solo sessions do not', () async {
|
||||
await orch.spawn(spec('solo'));
|
||||
expect(orch.broker.members, isEmpty);
|
||||
// The 'user' virtual member is always registered in the broker (T-180).
|
||||
final agentNames = orch.broker.members.where((m) => m.id != 'user').map((m) => m.name);
|
||||
expect(agentNames, isEmpty);
|
||||
await orch.spawn(teamSpec('primary', 'lead', 'lead'));
|
||||
expect(orch.broker.members.map((m) => m.name), ['lead']);
|
||||
final agentNamesAfter = orch.broker.members.where((m) => m.id != 'user').map((m) => m.name);
|
||||
expect(agentNamesAfter, ['lead']);
|
||||
});
|
||||
|
||||
test('a message between team members is delivered into the target session stdin', () async {
|
||||
@@ -121,7 +124,9 @@ void main() {
|
||||
await orch.spawn(teamSpec('primary', 'lead', 'lead'));
|
||||
await orch.spawn(teamSpec('teammate:tyre', 'tyre', 'teammate'));
|
||||
await orch.close('teammate:tyre');
|
||||
expect(orch.broker.members.map((m) => m.name), ['lead']);
|
||||
// 'user' is always in the broker (T-180); only agent members checked here.
|
||||
final agentNames = orch.broker.members.where((m) => m.id != 'user').map((m) => m.name);
|
||||
expect(agentNames, ['lead']);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -52,14 +52,22 @@ void main() {
|
||||
test('broadcast reaches every other member but not the sender', () async {
|
||||
broker.addMember(const TeamMemberRef(id: 'teammate:qatux', name: 'qatux', role: 'teammate'));
|
||||
final r = decode(await lead.callTool('broadcast', {'text': 'standup'}));
|
||||
expect((r['recipients'] as List).toSet(), {'tyre', 'qatux'});
|
||||
expect(delivered.map((d) => d.$1).toSet(), {'teammate:tyre', 'teammate:qatux'});
|
||||
// 'user' is also in the recipients (T-180 virtual member), but has no
|
||||
// stdin delivery (handled by the chat model, not the MessageDelivery callback).
|
||||
final recipients = (r['recipients'] as List).toSet();
|
||||
expect(recipients, containsAll({'tyre', 'qatux'}));
|
||||
// Only real sessions get stdin delivery; 'user' is skipped in _enqueue.
|
||||
expect(delivered.map((d) => d.$1).toSet(), containsAll({'teammate:tyre', 'teammate:qatux'}));
|
||||
expect(delivered.map((d) => d.$1), isNot(contains('user')));
|
||||
});
|
||||
|
||||
test('list_teammates returns the other members with roles', () async {
|
||||
final r = decode(await lead.callTool('list_teammates', {}));
|
||||
final mates = r['teammates'] as List;
|
||||
expect(mates.single, {'name': 'tyre', 'role': 'teammate'});
|
||||
// Now includes the virtual 'user' member (T-180) in addition to 'tyre'.
|
||||
expect(mates.map((m) => m['name']).toSet(), contains('tyre'));
|
||||
final tyreMate = mates.firstWhere((m) => m['name'] == 'tyre') as Map;
|
||||
expect(tyreMate['role'], 'teammate');
|
||||
});
|
||||
|
||||
test('a claimed task is visible to every member as shared state', () async {
|
||||
@@ -118,7 +126,8 @@ void main() {
|
||||
|
||||
test('removing an unknown member is a no-op', () {
|
||||
broker.removeMember('teammate:ghost');
|
||||
expect(broker.members.map((m) => m.name).toSet(), {'lead', 'tyre'});
|
||||
// 'user' is always present (T-180 virtual member).
|
||||
expect(broker.members.map((m) => m.name).toSet(), {'lead', 'tyre', 'user'});
|
||||
});
|
||||
|
||||
test('the MCP tool surface lists all six team tools', () {
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
/// 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 Future<void>.delayed(Duration.zero);
|
||||
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 Future<void>.delayed(Duration.zero);
|
||||
// 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 Future<void>.delayed(Duration.zero);
|
||||
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 Future<void>.delayed(Duration.zero);
|
||||
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 Future<void>.delayed(Duration.zero);
|
||||
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 Future<void>.delayed(Duration.zero);
|
||||
// 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 Future<void>.delayed(Duration.zero);
|
||||
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 Future<void>.delayed(Duration.zero);
|
||||
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 Future<void>.delayed(Duration.zero);
|
||||
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 Future<void>.delayed(Duration.zero);
|
||||
// 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 Future<void>.delayed(Duration.zero);
|
||||
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 Future<void>.delayed(Duration.zero);
|
||||
expect(done, isTrue);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
/// Widget tests for TeamChatSidebar and TeamChatPane (T-180).
|
||||
library;
|
||||
|
||||
import 'package:clide/builtin/claude/src/team_broker.dart';
|
||||
import 'package:clide/builtin/claude/src/team_chat_model.dart';
|
||||
import 'package:clide/builtin/claude/src/team_chat_sidebar.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
import '../../helpers/widget_harness.dart';
|
||||
|
||||
void main() {
|
||||
late KernelFixture f;
|
||||
late TeamBroker broker;
|
||||
late TeamChatModel model;
|
||||
|
||||
setUp(() async {
|
||||
f = await KernelFixture.create();
|
||||
broker = TeamBroker(deliver: (_, __) {});
|
||||
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();
|
||||
f.dispose();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TeamChatSidebar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
group('TeamChatSidebar', () {
|
||||
Widget sidebar({VoidCallback? onPopOut}) => harness(
|
||||
f,
|
||||
SizedBox(
|
||||
width: 220,
|
||||
height: 400,
|
||||
child: TeamChatSidebar(
|
||||
model: model,
|
||||
broker: broker,
|
||||
onPopOut: onPopOut ?? () {},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
testWidgets('renders MESSAGES header', (tester) async {
|
||||
await tester.pumpWidget(sidebar());
|
||||
expect(find.text('MESSAGES'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows placeholder when no messages', (tester) async {
|
||||
await tester.pumpWidget(sidebar());
|
||||
expect(find.text('No messages yet.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders broker messages from the model', (tester) async {
|
||||
// Post via model directly so the message is already in the timeline
|
||||
// before we build the widget — avoids a pump/settle cycle.
|
||||
model.postAsUser('hello tyre', toName: 'tyre');
|
||||
await tester.pumpWidget(sidebar());
|
||||
await tester.pump();
|
||||
expect(find.text('hello tyre'), findsOneWidget);
|
||||
expect(find.text('No messages yet.'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('updates when a new message arrives', (tester) async {
|
||||
await tester.pumpWidget(sidebar());
|
||||
await tester.pump();
|
||||
expect(find.text('No messages yet.'), findsOneWidget);
|
||||
|
||||
// postAsUser adds to _messages synchronously and fires _changeCtl.add,
|
||||
// which synchronously calls setState() in the sidebar's listener.
|
||||
// Two pumps: one to process the microtask queue (stream event delivery),
|
||||
// one to render the resulting rebuild frame.
|
||||
model.postAsUser('live message', toName: 'tyre');
|
||||
await tester.pump(); // deliver stream event → setState
|
||||
await tester.pump(); // render rebuild frame
|
||||
|
||||
expect(find.text('live message'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('postAsUser adds a message from the user', (tester) async {
|
||||
await tester.pumpWidget(sidebar());
|
||||
await tester.pump();
|
||||
|
||||
model.postAsUser('hello team');
|
||||
await tester.pump(); // deliver stream event → setState
|
||||
await tester.pump(); // render rebuild frame
|
||||
|
||||
// The message text appears in the timeline.
|
||||
expect(find.text('hello team'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('has a chat input field', (tester) async {
|
||||
await tester.pumpWidget(sidebar());
|
||||
await tester.pumpAndSettle();
|
||||
final chatField = find.byWidgetPredicate((w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-sidebar');
|
||||
expect(chatField, findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('submitting the composer calls postAsUser', (tester) async {
|
||||
await tester.pumpWidget(sidebar());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final chatField = find.byWidgetPredicate((w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-sidebar');
|
||||
await tester.enterText(chatField, 'broadcast msg');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pump();
|
||||
|
||||
expect(model.messages.any((m) => m.text == 'broadcast msg'), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('@name tag routes to the named member', (tester) async {
|
||||
await tester.pumpWidget(sidebar());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final chatField = find.byWidgetPredicate((w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-sidebar');
|
||||
await tester.enterText(chatField, '@tyre pick this up');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pump();
|
||||
|
||||
expect(model.messages.any((m) => m.to == 'tyre' && m.text == 'pick this up'), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('pop-out icon calls onPopOut', (tester) async {
|
||||
var popped = false;
|
||||
// Use a tall harness so the MESSAGES header (and its pop-out icon) is
|
||||
// always in view and tappable.
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
SizedBox(
|
||||
width: 300,
|
||||
height: 800,
|
||||
child: TeamChatSidebar(
|
||||
model: model,
|
||||
broker: broker,
|
||||
onPopOut: () => popped = true,
|
||||
),
|
||||
),
|
||||
));
|
||||
await tester.pump();
|
||||
|
||||
// The pop-out icon is wired via Semantics(label: 'Open full chat pane').
|
||||
// Use byWidgetPredicate to traverse the full widget tree regardless of
|
||||
// viewport clipping (canSizeOverlay harness pitfall — see T-180 notes).
|
||||
final popOutFinder = find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Open full chat pane');
|
||||
expect(popOutFinder, findsOneWidget);
|
||||
await tester.tap(popOutFinder);
|
||||
await tester.pump();
|
||||
expect(popped, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('shows only last 5 messages in compact feed', (tester) async {
|
||||
// Post 8 messages directly via model (synchronous, no broker stream delay).
|
||||
for (var i = 0; i < 8; i++) {
|
||||
model.postAsUser('message $i', toName: 'tyre');
|
||||
}
|
||||
await tester.pumpWidget(sidebar());
|
||||
await tester.pump();
|
||||
// Only messages 3-7 visible (last 5).
|
||||
expect(find.text('message 7'), findsOneWidget);
|
||||
expect(find.text('message 0'), findsNothing);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TeamChatPane
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
group('TeamChatPane', () {
|
||||
Widget pane() => harness(
|
||||
f,
|
||||
SizedBox(
|
||||
width: 400,
|
||||
height: 600,
|
||||
child: TeamChatPane(model: model, broker: broker),
|
||||
),
|
||||
);
|
||||
|
||||
testWidgets('renders Team Chat header', (tester) async {
|
||||
await tester.pumpWidget(pane());
|
||||
expect(find.text('Team Chat'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('shows placeholder when empty', (tester) async {
|
||||
await tester.pumpWidget(pane());
|
||||
expect(find.text('No messages yet.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders messages from the model', (tester) async {
|
||||
model.postAsUser('pane message', toName: 'tyre');
|
||||
await tester.pumpWidget(pane());
|
||||
await tester.pump();
|
||||
expect(find.text('pane message'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('has an Interrupt tickbox starting unchecked', (tester) async {
|
||||
await tester.pumpWidget(pane());
|
||||
await tester.pump();
|
||||
expect(find.text('Interrupt'), findsOneWidget);
|
||||
// The tickbox is implemented as a Container that is empty when unchecked
|
||||
// and shows a check icon when checked. Verify by checking that the check
|
||||
// icon (PhosphorIcons.check) is NOT rendered when unchecked.
|
||||
// We use the Semantics widget's checked property which maps to isChecked.
|
||||
final interruptFinder = find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Interrupt target session');
|
||||
// Widget exists in tree.
|
||||
expect(interruptFinder, findsOneWidget);
|
||||
// Semantics.checked is false when unchecked.
|
||||
final sem = interruptFinder.evaluate().single.widget as Semantics;
|
||||
expect(sem.properties.checked, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('tapping Interrupt toggles the tickbox', (tester) async {
|
||||
await tester.pumpWidget(pane());
|
||||
await tester.pump();
|
||||
|
||||
final interruptArea = find.text('Interrupt');
|
||||
await tester.tap(interruptArea);
|
||||
await tester.pump();
|
||||
|
||||
// Re-find: the Semantics widget's checked property is now true.
|
||||
final interruptFinder = find.byWidgetPredicate((w) => w is Semantics && w.properties.label == 'Interrupt target session');
|
||||
final sem = interruptFinder.evaluate().single.widget as Semantics;
|
||||
expect(sem.properties.checked, isTrue);
|
||||
});
|
||||
|
||||
testWidgets('submitting the pane composer posts as user', (tester) async {
|
||||
await tester.pumpWidget(pane());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final chatField = find.byWidgetPredicate((w) => w is EditableText && w.focusNode.debugLabel == 'team-chat-pane');
|
||||
await tester.enterText(chatField, '@tyre check this');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pump();
|
||||
|
||||
expect(model.messages.any((m) => m.to == 'tyre' && m.text == 'check this'), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('sidebar and pane share the same model (both surfaces update)', (tester) async {
|
||||
await tester.pumpWidget(harness(
|
||||
f,
|
||||
SizedBox(
|
||||
width: 800,
|
||||
height: 600,
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 220,
|
||||
child: TeamChatSidebar(
|
||||
model: model,
|
||||
broker: broker,
|
||||
onPopOut: () {},
|
||||
),
|
||||
),
|
||||
Expanded(child: TeamChatPane(model: model, broker: broker)),
|
||||
],
|
||||
),
|
||||
),
|
||||
));
|
||||
await tester.pump();
|
||||
|
||||
// Posting from the model shows up in both surfaces.
|
||||
model.postAsUser('shared message');
|
||||
await tester.pump(); // deliver stream events → setState in each widget
|
||||
await tester.pump(); // render rebuild frames
|
||||
|
||||
expect(find.text('shared message'), findsNWidgets(2));
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user