make sidebar filter boxes CLI-addressable via the MessageBus (T-270)
The sidebar/dock filter fields (the shared ClideFilterBox) had no CLI peer — a one-way, UI-only affordance that broke D-6 parity. Add the drive+observe verb `clide ui filter <address> [<text>]`, routed entirely through the kernel MessageBus pub/sub so a box reacts to a published message identically whether the trigger was a UI keystroke or the CLI — keeping extensions first-class (no dispatcher→widget wiring). - ClideFilterBox gains an `address`; when set it listens on `filter.set` for its address and republishes its value on `filter.state`. Null address keeps the box a kernel-free UI widget. - FilterStateCache (new kernel service) caches the latest `filter.state` per address — the bus has no retention, so this backs the observe-half. - ui.filter: with text → publishes `filter.set` (drive); without → reads the cache (observe). Honest toolError when there is no live UI. - Address every box: decisions/tickets/files/git/output/problems panes, the four search boxes, and the pql search/query/markdown inputs. Addresses are the ids from `clide pane list` (e.g. decisions.panel). settings.json: allow the `clide` CLI + relevant skills. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
/// Tests for [ClideFilterBox], focused on the CLI-addressable behaviour
|
||||
/// added in T-270: an addressed box reacts to `filter.set` messages and
|
||||
/// republishes its value on `filter.state`, while a plain (unaddressed)
|
||||
/// box stays a kernel-free UI widget.
|
||||
///
|
||||
/// The box has an internal `Expanded`, so it needs a bounded-width
|
||||
/// ancestor — we build a tight tree rather than the shared `harness()`
|
||||
/// (whose canSizeOverlay hands unbounded width).
|
||||
library;
|
||||
|
||||
import 'package:clide/kernel/kernel.dart';
|
||||
import 'package:clide/widgets/widgets.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import '../../helpers/kernel_fixture.dart';
|
||||
import '../../helpers/widget_harness.dart' show pumpAsync;
|
||||
|
||||
void main() {
|
||||
late KernelFixture fixture;
|
||||
|
||||
setUp(() async => fixture = await KernelFixture.create());
|
||||
tearDown(() async => fixture.dispose());
|
||||
|
||||
// Tight, bounded tree with a live ClideKernel so an addressed box can
|
||||
// resolve the MessageBus.
|
||||
Future<void> mountAddressed(WidgetTester tester, Widget child) {
|
||||
return tester.pumpWidget(
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: MediaQuery(
|
||||
data: const MediaQueryData(),
|
||||
child: ClideKernel(
|
||||
services: fixture.services,
|
||||
child: ClideTheme(
|
||||
controller: fixture.services.theme,
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: SizedBox(width: 300, height: 60, child: child),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String editableText(WidgetTester tester) => tester.widget<EditableText>(find.byType(EditableText)).controller.text;
|
||||
|
||||
testWidgets('filter.set drives the box: updates field, fires onChanged, reports state', (tester) async {
|
||||
String? captured;
|
||||
await mountAddressed(tester, ClideFilterBox(address: 'test.box', onChanged: (v) => captured = v));
|
||||
await pumpAsync(tester);
|
||||
|
||||
fixture.services.messages.publish('test.box', 'filter.set', {'query': 'git'});
|
||||
await pumpAsync(tester);
|
||||
|
||||
expect(captured, 'git', reason: 'onChanged fires for a programmatic set');
|
||||
expect(editableText(tester), 'git', reason: 'the field shows the pushed value');
|
||||
expect(fixture.services.filterStates.get('test.box'), 'git', reason: 'state is reported back for observe');
|
||||
});
|
||||
|
||||
testWidgets('typing reports the value on filter.state (for observe)', (tester) async {
|
||||
await mountAddressed(tester, ClideFilterBox(address: 'test.box', onChanged: (_) {}));
|
||||
await pumpAsync(tester);
|
||||
// Initial mount reports the empty value.
|
||||
expect(fixture.services.filterStates.get('test.box'), '');
|
||||
|
||||
await tester.enterText(find.byType(EditableText), 'lib');
|
||||
await tester.pump(const Duration(milliseconds: 250)); // past the 200ms debounce
|
||||
expect(fixture.services.filterStates.get('test.box'), 'lib');
|
||||
});
|
||||
|
||||
testWidgets('only the addressed box reacts (addresses are isolated)', (tester) async {
|
||||
String? captured;
|
||||
await mountAddressed(tester, ClideFilterBox(address: 'test.box', onChanged: (v) => captured = v));
|
||||
await pumpAsync(tester);
|
||||
|
||||
fixture.services.messages.publish('other.box', 'filter.set', {'query': 'nope'});
|
||||
await pumpAsync(tester);
|
||||
expect(captured, isNull);
|
||||
expect(editableText(tester), isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('the clear affordance empties the field and reports an empty value', (tester) async {
|
||||
String? captured;
|
||||
await mountAddressed(tester, ClideFilterBox(address: 'test.box', onChanged: (v) => captured = v));
|
||||
await pumpAsync(tester);
|
||||
|
||||
await tester.enterText(find.byType(EditableText), 'git');
|
||||
await tester.pump(const Duration(milliseconds: 250));
|
||||
expect(captured, 'git');
|
||||
|
||||
await tester.tap(find.byType(GestureDetector));
|
||||
await tester.pump();
|
||||
expect(captured, '');
|
||||
expect(editableText(tester), isEmpty);
|
||||
expect(fixture.services.filterStates.get('test.box'), '');
|
||||
});
|
||||
|
||||
testWidgets('an unaddressed box needs no ClideKernel and still fires onChanged', (tester) async {
|
||||
String? captured;
|
||||
// ClideTheme is required by every box's build; ClideKernel is NOT — an
|
||||
// unaddressed box must never reach for the MessageBus. No kernel here.
|
||||
await tester.pumpWidget(
|
||||
Directionality(
|
||||
textDirection: TextDirection.ltr,
|
||||
child: MediaQuery(
|
||||
data: const MediaQueryData(),
|
||||
child: ClideTheme(
|
||||
controller: fixture.services.theme,
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: SizedBox(width: 300, height: 60, child: ClideFilterBox(onChanged: (v) => captured = v)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.enterText(find.byType(EditableText), 'x');
|
||||
await tester.pump(const Duration(milliseconds: 250));
|
||||
expect(captured, 'x');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user