chore: adopt Dart 3.9 toolchain — honest floor + tall-style reformat (T-353)

Raise the declared minimums in pubspec.yaml to what our deps already
require: Flutter >=3.35.0 / Dart >=3.9.0 (was 3.19.0 / 3.5.0). alchemist
0.12 needs Flutter 3.32; Dart 3.9 first ships in Flutter 3.35, so 3.35 is
the binding floor. Pin the exact build toolchain in .fvmrc (Flutter
3.44.1).

Moving to the Dart 3.9 language level switches `dart format` to the new
"tall" style and enables two new lints. This commit is the resulting
mechanical churn, isolated from any behaviour change:
  - whole-tree `dart format` reformat (tall style)
  - `dart fix` for unnecessary_underscores + use_null_aware_elements

No runtime behaviour change; `make test` green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-11 12:11:53 +02:00
co-authored by Claude Opus 4.8
parent bcea5f15b7
commit 6d0ebab721
444 changed files with 7587 additions and 12849 deletions
+14 -9
View File
@@ -18,9 +18,9 @@ void main() {
tearDown(() => f.dispose());
Widget box(VoidCallback onDismiss) => MediaQuery(
data: const MediaQueryData(size: Size(800, 600)),
child: ClideLightbox(onDismiss: onDismiss, child: const SizedBox(width: 200, height: 150)),
);
data: const MediaQueryData(size: Size(800, 600)),
child: ClideLightbox(onDismiss: onDismiss, child: const SizedBox(width: 200, height: 150)),
);
testWidgets('renders the zoom hint and an InteractiveViewer', (tester) async {
await tester.pumpWidget(harness(f, box(() {})));
@@ -63,13 +63,18 @@ void main() {
testWidgets('a tap on the dimmed canvas dismisses; a tap on the image does not (T-309)', (tester) async {
final img = (await tester.runAsync(() => createTestImage(width: 100, height: 100)))!; // 1:1; real async
var dismissed = false;
await tester.pumpWidget(harness(
f,
MediaQuery(
data: const MediaQueryData(size: Size(800, 600)),
child: ClideLightbox(onDismiss: () => dismissed = true, child: RawImage(image: img, fit: BoxFit.contain)),
await tester.pumpWidget(
harness(
f,
MediaQuery(
data: const MediaQueryData(size: Size(800, 600)),
child: ClideLightbox(
onDismiss: () => dismissed = true,
child: RawImage(image: img, fit: BoxFit.contain),
),
),
),
));
);
await tester.pumpAndSettle();
// The 94% box is ~752×564; a 1:1 image fits to 564×564 centred, leaving
+3 -29
View File
@@ -14,30 +14,13 @@ void main() {
tearDown(() => f.dispose());
testWidgets('renders title + subtitle', (tester) async {
await tester.pumpWidget(
harness(
f,
const ClidePaneChrome(
title: 'terminal — ~/clide',
subtitle: 'bash · 80×24',
child: SizedBox.shrink(),
),
),
);
await tester.pumpWidget(harness(f, const ClidePaneChrome(title: 'terminal — ~/clide', subtitle: 'bash · 80×24', child: SizedBox.shrink())));
expect(find.text('terminal — ~/clide'), findsOneWidget);
expect(find.text('bash · 80×24'), findsOneWidget);
});
testWidgets('no close button when onClose is null', (tester) async {
await tester.pumpWidget(
harness(
f,
const ClidePaneChrome(
title: 'primary claude',
child: SizedBox.shrink(),
),
),
);
await tester.pumpWidget(harness(f, const ClidePaneChrome(title: 'primary claude', child: SizedBox.shrink())));
final handle = tester.ensureSemantics();
expect(find.bySemanticsLabel('Close pane'), findsNothing);
handle.dispose();
@@ -45,16 +28,7 @@ void main() {
testWidgets('close button invokes onClose when present', (tester) async {
var pressed = false;
await tester.pumpWidget(
harness(
f,
ClidePaneChrome(
title: 'terminal',
onClose: () => pressed = true,
child: const SizedBox.shrink(),
),
),
);
await tester.pumpWidget(harness(f, ClidePaneChrome(title: 'terminal', onClose: () => pressed = true, child: const SizedBox.shrink())));
final handle = tester.ensureSemantics();
expect(find.bySemanticsLabel('Close pane'), findsOneWidget);
await tester.tap(find.bySemanticsLabel('Close pane'));
+31 -56
View File
@@ -72,10 +72,7 @@ After.
testWidgets('record-id link fires onRecordTap callback', (tester) async {
var tapped = '';
const src = 'See [D-1](#anchor) for details.';
await tester.pumpWidget(harness(
f,
ClideMarkdown(src, onRecordTap: (id) => tapped = id),
));
await tester.pumpWidget(harness(f, ClideMarkdown(src, onRecordTap: (id) => tapped = id)));
await tester.pumpAndSettle();
// The record-link path matches D-1 / Q-N / R-N / T-N and produces
// a tappable span. We can't easily simulate a TextSpan tap from
@@ -88,9 +85,7 @@ After.
testWidgets('record-id link tap actually invokes onRecordTap', (tester) async {
var tapped = '';
const src = '[D-1](#anchor)';
await tester.pumpWidget(
harness(f, ClideMarkdown(src, onRecordTap: (id) => tapped = id)),
);
await tester.pumpWidget(harness(f, ClideMarkdown(src, onRecordTap: (id) => tapped = id)));
await tester.pumpAndSettle();
// The link renders as a ClideTappable embedded in a WidgetSpan.
await tester.tap(find.text('D-1'));
@@ -200,10 +195,7 @@ After.
});
testWidgets('handles a language whose grammar is unavailable (falls back to plain text)', (tester) async {
await tester.pumpWidget(harness(
f,
const ClideCodeBlock(source: 'fn main() {}', language: 'rust'),
));
await tester.pumpWidget(harness(f, const ClideCodeBlock(source: 'fn main() {}', language: 'rust')));
await tester.pumpAndSettle();
// TreeSitterService can't load the grammar in tests → falls back
// to plain rendering.
@@ -226,16 +218,12 @@ After.
testWidgets('collapsed accordion does not render children; tap toggles', (tester) async {
var toggled = 0;
await tester.pumpWidget(harness(
f,
ClideAccordion(
label: 'Group',
count: 2,
expanded: false,
onToggle: () => toggled++,
children: const [Text('child-one'), Text('child-two')],
await tester.pumpWidget(
harness(
f,
ClideAccordion(label: 'Group', count: 2, expanded: false, onToggle: () => toggled++, children: const [Text('child-one'), Text('child-two')]),
),
));
);
await tester.pumpAndSettle();
expect(find.text('Group · 2'), findsOneWidget);
expect(find.text('child-one'), findsNothing);
@@ -244,17 +232,9 @@ After.
});
testWidgets('expanded accordion shows children; leading widget renders', (tester) async {
await tester.pumpWidget(harness(
f,
ClideAccordion(
label: 'Open',
count: 1,
expanded: true,
onToggle: () {},
leading: const Text('LEAD'),
children: const [Text('only-child')],
),
));
await tester.pumpWidget(
harness(f, ClideAccordion(label: 'Open', count: 1, expanded: true, onToggle: () {}, leading: const Text('LEAD'), children: const [Text('only-child')])),
);
await tester.pumpAndSettle();
expect(find.text('only-child'), findsOneWidget);
expect(find.text('LEAD'), findsOneWidget);
@@ -269,36 +249,25 @@ After.
testWidgets('wraps a Scrollable child and exposes ScrollbarTheme', (tester) async {
final controller = ScrollController();
addTearDown(controller.dispose);
await tester.pumpWidget(harness(
f,
SizedBox(
height: 100,
child: ClideScrollbar(
controller: controller,
child: SingleChildScrollView(
await tester.pumpWidget(
harness(
f,
SizedBox(
height: 100,
child: ClideScrollbar(
controller: controller,
child: const SizedBox(height: 1000),
child: SingleChildScrollView(controller: controller, child: const SizedBox(height: 1000)),
),
),
),
));
);
await tester.pumpAndSettle();
expect(find.byType(ClideScrollbar), findsOneWidget);
});
test('ScrollbarTheme.updateShouldNotify detects colour changes', () {
const a = ScrollbarTheme(
slider: Color(0xFF000000),
sliderHover: Color(0xFF111111),
track: Color(0xFF222222),
child: SizedBox.shrink(),
);
const b = ScrollbarTheme(
slider: Color(0xFFFFFFFF),
sliderHover: Color(0xFF111111),
track: Color(0xFF222222),
child: SizedBox.shrink(),
);
const a = ScrollbarTheme(slider: Color(0xFF000000), sliderHover: Color(0xFF111111), track: Color(0xFF222222), child: SizedBox.shrink());
const b = ScrollbarTheme(slider: Color(0xFFFFFFFF), sliderHover: Color(0xFF111111), track: Color(0xFF222222), child: SizedBox.shrink());
expect(a.updateShouldNotify(b), isTrue);
expect(a.updateShouldNotify(a), isFalse);
});
@@ -311,10 +280,16 @@ After.
testWidgets('renders Semantics live region wrapping a TerminalView', (tester) async {
final terminal = Terminal(maxLines: 100, onOutput: (_) {});
await tester.pumpWidget(harness(
f,
SizedBox(width: 400, height: 200, child: ClidePtyView(terminal: terminal, label: 'test-pane')),
));
await tester.pumpWidget(
harness(
f,
SizedBox(
width: 400,
height: 200,
child: ClidePtyView(terminal: terminal, label: 'test-pane'),
),
),
);
await tester.pumpAndSettle();
expect(find.byType(ClidePtyView), findsOneWidget);
// The TerminalView inside renders the actual viewport.
+5 -25
View File
@@ -2,13 +2,7 @@ import 'package:clide/widgets/src/multitab_controller.dart';
import 'package:flutter_test/flutter_test.dart';
MultitabEntry<String> entry(String id, {bool closeable = true, bool reorderable = true}) {
return MultitabEntry<String>(
id: id,
title: id,
payload: id,
closeable: closeable,
reorderable: reorderable,
);
return MultitabEntry<String>(id: id, title: id, payload: id, closeable: closeable, reorderable: reorderable);
}
void main() {
@@ -94,31 +88,20 @@ void main() {
});
test('reorder no-ops on non-reorderable entries', () {
final c = MultitabController<String>(initial: [
entry('p', reorderable: false),
entry('a'),
]);
final c = MultitabController<String>(initial: [entry('p', reorderable: false), entry('a')]);
c.reorder('p', 1);
expect(c.entries.map((e) => e.id), ['p', 'a']);
});
test('reorder cannot move a tab past a pinned barrier', () {
final c = MultitabController<String>(initial: [
entry('p', reorderable: false),
entry('a'),
entry('b'),
]);
final c = MultitabController<String>(initial: [entry('p', reorderable: false), entry('a'), entry('b')]);
// 'a' tries to land at index 0 — blocked by pinned 'p'.
c.reorder('a', 0);
expect(c.entries.map((e) => e.id), ['p', 'a', 'b']);
});
test('reorder respects barriers on the right', () {
final c = MultitabController<String>(initial: [
entry('a'),
entry('b'),
entry('p', reorderable: false),
]);
final c = MultitabController<String>(initial: [entry('a'), entry('b'), entry('p', reorderable: false)]);
// 'a' tries to land past pinned 'p' — clamped to before it.
c.reorder('a', 2);
expect(c.entries.map((e) => e.id), ['b', 'a', 'p']);
@@ -146,10 +129,7 @@ void main() {
test('replace rejects id changes', () {
final c = MultitabController<String>(initial: [entry('a')]);
expect(
() => c.replace('a', MultitabEntry<String>(id: 'b', title: 'b', payload: 'b')),
throwsStateError,
);
expect(() => c.replace('a', MultitabEntry<String>(id: 'b', title: 'b', payload: 'b')), throwsStateError);
});
test('notifies listeners on every mutation', () {
+21 -94
View File
@@ -5,18 +5,10 @@ import 'package:flutter_test/flutter_test.dart';
import '../helpers/kernel_fixture.dart';
import '../helpers/widget_harness.dart';
Finder _closeIcons() => find.byWidgetPredicate(
(w) => w is ClideIcon && w.painter is CloseIcon,
);
Finder _closeIcons() => find.byWidgetPredicate((w) => w is ClideIcon && w.painter is CloseIcon);
MultitabEntry<String> entry(String id, {bool closeable = true, bool reorderable = true}) {
return MultitabEntry<String>(
id: id,
title: id,
payload: id,
closeable: closeable,
reorderable: reorderable,
);
return MultitabEntry<String>(id: id, title: id, payload: id, closeable: closeable, reorderable: reorderable);
}
Widget body(BuildContext _, MultitabEntry<String> e) => SizedBox(key: ValueKey('body-${e.id}'), child: Text('body:${e.payload}'));
@@ -39,12 +31,7 @@ class _CountingBodyState extends State<_CountingBody> {
@override
Widget build(BuildContext context) {
return GestureDetector(
key: ValueKey('counting-${widget.id}'),
behavior: HitTestBehavior.opaque,
onTap: _bump,
child: const SizedBox.expand(),
);
return GestureDetector(key: ValueKey('counting-${widget.id}'), behavior: HitTestBehavior.opaque, onTap: _bump, child: const SizedBox.expand());
}
}
@@ -55,12 +42,8 @@ void main() {
tearDown(() => f.dispose());
testWidgets('renders one tab per entry and the active body', (tester) async {
final c = MultitabController<String>(
initial: [entry('primary', closeable: false), entry('s1'), entry('s2')],
);
await tester.pumpWidget(
harness(f, MultitabPane<String>(controller: c, bodyBuilder: body)),
);
final c = MultitabController<String>(initial: [entry('primary', closeable: false), entry('s1'), entry('s2')]);
await tester.pumpWidget(harness(f, MultitabPane<String>(controller: c, bodyBuilder: body)));
expect(find.text('primary'), findsOneWidget);
expect(find.text('s1'), findsOneWidget);
@@ -72,9 +55,7 @@ void main() {
testWidgets('tapping a tab activates it and swaps the body', (tester) async {
final c = MultitabController<String>(initial: [entry('a'), entry('b')]);
await tester.pumpWidget(
harness(f, MultitabPane<String>(controller: c, bodyBuilder: body)),
);
await tester.pumpWidget(harness(f, MultitabPane<String>(controller: c, bodyBuilder: body)));
expect(find.byKey(const ValueKey('body-a')), findsOneWidget);
await tester.tap(find.text('b'));
@@ -88,16 +69,7 @@ void main() {
testWidgets('add button calls onAddRequested when set', (tester) async {
final c = MultitabController<String>(initial: [entry('a')]);
var added = 0;
await tester.pumpWidget(
harness(
f,
MultitabPane<String>(
controller: c,
bodyBuilder: body,
onAddRequested: () => added++,
),
),
);
await tester.pumpWidget(harness(f, MultitabPane<String>(controller: c, bodyBuilder: body, onAddRequested: () => added++)));
await tester.tap(find.bySemanticsLabel('New tab'));
await tester.pumpAndSettle();
@@ -106,19 +78,13 @@ void main() {
testWidgets('add button is absent when onAddRequested is null', (tester) async {
final c = MultitabController<String>(initial: [entry('a')]);
await tester.pumpWidget(
harness(f, MultitabPane<String>(controller: c, bodyBuilder: body)),
);
await tester.pumpWidget(harness(f, MultitabPane<String>(controller: c, bodyBuilder: body)));
expect(find.bySemanticsLabel('New tab'), findsNothing);
});
testWidgets('non-closeable tabs do not render a close glyph', (tester) async {
final c = MultitabController<String>(
initial: [entry('p', closeable: false), entry('s')],
);
await tester.pumpWidget(
harness(f, MultitabPane<String>(controller: c, bodyBuilder: body)),
);
final c = MultitabController<String>(initial: [entry('p', closeable: false), entry('s')]);
await tester.pumpWidget(harness(f, MultitabPane<String>(controller: c, bodyBuilder: body)));
// A pinned tab has no close target; a closeable one does (it's
// hidden via Opacity until hover, but still in the tree).
// Two tabs total, one × glyph for 's'.
@@ -126,12 +92,8 @@ void main() {
});
testWidgets('default close behavior removes the entry', (tester) async {
final c = MultitabController<String>(
initial: [entry('p', closeable: false), entry('s')],
);
await tester.pumpWidget(
harness(f, MultitabPane<String>(controller: c, bodyBuilder: body)),
);
final c = MultitabController<String>(initial: [entry('p', closeable: false), entry('s')]);
await tester.pumpWidget(harness(f, MultitabPane<String>(controller: c, bodyBuilder: body)));
await tester.tap(_closeIcons());
await tester.pumpAndSettle();
@@ -142,16 +104,7 @@ void main() {
testWidgets('onCloseRequested overrides default removal', (tester) async {
final c = MultitabController<String>(initial: [entry('a'), entry('b')]);
MultitabEntry<String>? closed;
await tester.pumpWidget(
harness(
f,
MultitabPane<String>(
controller: c,
bodyBuilder: body,
onCloseRequested: (e) => closed = e,
),
),
);
await tester.pumpWidget(harness(f, MultitabPane<String>(controller: c, bodyBuilder: body, onCloseRequested: (e) => closed = e)));
// Both tabs are closeable; tap the first × encountered.
await tester.tap(_closeIcons().first);
@@ -164,9 +117,7 @@ void main() {
testWidgets('rebuilds when the controller notifies', (tester) async {
final c = MultitabController<String>(initial: [entry('a')]);
await tester.pumpWidget(
harness(f, MultitabPane<String>(controller: c, bodyBuilder: body)),
);
await tester.pumpWidget(harness(f, MultitabPane<String>(controller: c, bodyBuilder: body)));
expect(find.bySemanticsLabel('b'), findsNothing);
c.add(entry('b'));
@@ -176,17 +127,13 @@ void main() {
testWidgets('empty controller renders no body', (tester) async {
final c = MultitabController<String>();
await tester.pumpWidget(
harness(f, MultitabPane<String>(controller: c, bodyBuilder: body)),
);
await tester.pumpWidget(harness(f, MultitabPane<String>(controller: c, bodyBuilder: body)));
expect(find.byKey(const ValueKey('body-a')), findsNothing);
});
testWidgets('drag a tab onto another to reorder', (tester) async {
final c = MultitabController<String>(initial: [entry('a'), entry('b'), entry('c')]);
await tester.pumpWidget(
harness(f, MultitabPane<String>(controller: c, bodyBuilder: body)),
);
await tester.pumpWidget(harness(f, MultitabPane<String>(controller: c, bodyBuilder: body)));
// Drag tab 'a' to where tab 'c' sits.
final from = tester.getCenter(find.text('a'));
@@ -202,14 +149,8 @@ void main() {
});
testWidgets('drag respects pinned barrier', (tester) async {
final c = MultitabController<String>(initial: [
entry('p', reorderable: false),
entry('a'),
entry('b'),
]);
await tester.pumpWidget(
harness(f, MultitabPane<String>(controller: c, bodyBuilder: body)),
);
final c = MultitabController<String>(initial: [entry('p', reorderable: false), entry('a'), entry('b')]);
await tester.pumpWidget(harness(f, MultitabPane<String>(controller: c, bodyBuilder: body)));
// Try to drag 'a' before pinned 'p' — controller's barrier
// logic should reject and the order stays.
@@ -226,13 +167,8 @@ void main() {
});
testWidgets('pinned tabs are not draggable', (tester) async {
final c = MultitabController<String>(initial: [
entry('p', reorderable: false),
entry('a'),
]);
await tester.pumpWidget(
harness(f, MultitabPane<String>(controller: c, bodyBuilder: body)),
);
final c = MultitabController<String>(initial: [entry('p', reorderable: false), entry('a')]);
await tester.pumpWidget(harness(f, MultitabPane<String>(controller: c, bodyBuilder: body)));
// Attempt to drag pinned 'p' to position of 'a'.
final from = tester.getCenter(find.text('p'));
@@ -302,16 +238,7 @@ void main() {
testWidgets('allowReorder=false disables drag entirely', (tester) async {
final c = MultitabController<String>(initial: [entry('a'), entry('b')]);
await tester.pumpWidget(
harness(
f,
MultitabPane<String>(
controller: c,
bodyBuilder: body,
allowReorder: false,
),
),
);
await tester.pumpWidget(harness(f, MultitabPane<String>(controller: c, bodyBuilder: body, allowReorder: false)));
final from = tester.getCenter(find.text('a'));
final to = tester.getCenter(find.text('b'));
+24 -15
View File
@@ -19,11 +19,15 @@ void main() {
setUp(() async {
f = await KernelFixture.create();
f.ipc.stub(
'files.walk',
(_) async => IpcResponse.ok(id: '1', data: const {
'files': ['lib/main.dart', 'lib/app.dart', 'README.md'],
'truncated': false,
}));
'files.walk',
(_) async => IpcResponse.ok(
id: '1',
data: const {
'files': ['lib/main.dart', 'lib/app.dart', 'README.md'],
'truncated': false,
},
),
);
f.ipc.stub('editor.open', (args) async => IpcResponse.ok(id: '1', data: {'path': args['path']}));
});
tearDown(() => f.dispose());
@@ -102,11 +106,15 @@ void main() {
testWidgets('truncated walk surfaces the limited-results hint', (tester) async {
f.ipc.stub(
'files.walk',
(_) async => IpcResponse.ok(id: '1', data: const {
'files': ['lib/main.dart'],
'truncated': true,
}));
'files.walk',
(_) async => IpcResponse.ok(
id: '1',
data: const {
'files': ['lib/main.dart'],
'truncated': true,
},
),
);
await tester.pumpWidget(harness(f, const QuickOpenOverlay()));
f.services.quickOpen.open();
await pumpAsync(tester);
@@ -151,11 +159,12 @@ void main() {
testWidgets('files.walk failure leaves the list empty (no crash)', (tester) async {
f.ipc.stub(
'files.walk',
(_) async => IpcResponse.err(
id: '1',
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'boom'),
));
'files.walk',
(_) async => IpcResponse.err(
id: '1',
error: IpcError(code: IpcExitCode.toolError, kind: IpcErrorKind.toolError, message: 'boom'),
),
);
await tester.pumpWidget(harness(f, const QuickOpenOverlay()));
f.services.quickOpen.open();
await pumpAsync(tester);
+4 -12
View File
@@ -21,7 +21,7 @@ void main() {
f,
ValueListenableBuilder<bool>(
valueListenable: present,
builder: (_, show, __) => show
builder: (_, show, _) => show
? Align(
alignment: Alignment.center,
child: ClideAnchoredOverlay(
@@ -30,7 +30,7 @@ void main() {
centered: centered,
autoFlip: false,
anchor: const SizedBox(width: 60, height: 24, child: ClideText('anchor')),
overlayBuilder: (_, __) => const SizedBox(width: 120, height: 60, child: ClideText('panel')),
overlayBuilder: (_, _) => const SizedBox(width: 120, height: 60, child: ClideText('panel')),
),
)
: const SizedBox.shrink(),
@@ -143,11 +143,7 @@ void main() {
addTearDown(c.dispose);
var picked = '';
// Right-edge trigger + end alignment — the menu extends left, on-screen.
await tester.pumpWidget(anchoredHarness(
f,
anchoredMenu(c, (v) => picked = v, align: ClideAnchorAlign.end),
alignment: Alignment.topRight,
));
await tester.pumpWidget(anchoredHarness(f, anchoredMenu(c, (v) => picked = v, align: ClideAnchorAlign.end), alignment: Alignment.topRight));
c.open();
await tester.pumpAndSettle();
await tester.tap(find.text('C'));
@@ -158,11 +154,7 @@ void main() {
testWidgets('autoFlip flips below to above when the anchor is near the bottom', (tester) async {
final c = ClideOverlayController();
addTearDown(c.dispose);
await tester.pumpWidget(anchoredHarness(
f,
anchoredMenu(c, (_) {}, side: ClideAnchorSide.below, autoFlip: true),
alignment: Alignment.bottomLeft,
));
await tester.pumpWidget(anchoredHarness(f, anchoredMenu(c, (_) {}, side: ClideAnchorSide.below, autoFlip: true), alignment: Alignment.bottomLeft));
c.open();
await tester.pumpAndSettle();
// The panel (its first item) sits ABOVE the trigger, not below.
+7 -40
View File
@@ -18,71 +18,38 @@ void main() {
});
testWidgets('renders the label text', (tester) async {
await tester.pumpWidget(
harness(f, const ClideButton(label: 'Save', onPressed: null)),
);
await tester.pumpWidget(harness(f, const ClideButton(label: 'Save', onPressed: null)));
expect(find.text('Save'), findsOneWidget);
});
testWidgets('emits a Semantics node with button: true + label', (tester) async {
await tester.pumpWidget(
harness(f, ClideButton(label: 'Commit', onPressed: () {})),
);
await tester.pumpWidget(harness(f, ClideButton(label: 'Commit', onPressed: () {})));
final semantics = tester.getSemantics(find.byType(ClideButton));
expect(semantics.label, 'Commit');
expect(
semantics.getSemanticsData().hasAction(SemanticsAction.tap),
isTrue,
reason: 'enabled button must expose tap action',
);
expect(semantics.getSemanticsData().hasAction(SemanticsAction.tap), isTrue, reason: 'enabled button must expose tap action');
});
testWidgets('semanticLabel overrides the visible label for a11y', (tester) async {
await tester.pumpWidget(
harness(
f,
ClideButton(
label: 'Save',
semanticLabel: 'Save document',
onPressed: () {},
),
),
);
await tester.pumpWidget(harness(f, ClideButton(label: 'Save', semanticLabel: 'Save document', onPressed: () {})));
final semantics = tester.getSemantics(find.byType(ClideButton));
expect(semantics.label, 'Save document');
});
testWidgets('semanticHint propagates to the Semantics node', (tester) async {
await tester.pumpWidget(
harness(
f,
ClideButton(
label: 'Pick',
semanticHint: 'Select a theme',
onPressed: () {},
),
),
);
await tester.pumpWidget(harness(f, ClideButton(label: 'Pick', semanticHint: 'Select a theme', onPressed: () {})));
final semantics = tester.getSemantics(find.byType(ClideButton));
expect(semantics.hint, 'Select a theme');
});
testWidgets('disabled button drops the tap action', (tester) async {
await tester.pumpWidget(
harness(f, const ClideButton(label: 'Nope', onPressed: null)),
);
await tester.pumpWidget(harness(f, const ClideButton(label: 'Nope', onPressed: null)));
final semantics = tester.getSemantics(find.byType(ClideButton));
expect(semantics.getSemanticsData().hasAction(SemanticsAction.tap), isFalse);
});
testWidgets('tap invokes onPressed', (tester) async {
var pressed = 0;
await tester.pumpWidget(
harness(
f,
ClideButton(label: 'Go', onPressed: () => pressed++),
),
);
await tester.pumpWidget(harness(f, ClideButton(label: 'Go', onPressed: () => pressed++)));
await tester.tap(find.byType(ClideButton));
expect(pressed, 1);
});
+36 -27
View File
@@ -27,24 +27,26 @@ void main() {
Color? color,
bool expanded = false,
}) async {
await tester.pumpWidget(harness(
f,
Align(
alignment: Alignment.topLeft,
child: SizedBox(
width: 400,
child: ClideCollapserCard(
label: label,
collapsedSummary: summary,
counter: counter,
status: status,
color: color,
initiallyExpanded: expanded,
children: children ?? const [Text('item body', textDirection: TextDirection.ltr)],
await tester.pumpWidget(
harness(
f,
Align(
alignment: Alignment.topLeft,
child: SizedBox(
width: 400,
child: ClideCollapserCard(
label: label,
collapsedSummary: summary,
counter: counter,
status: status,
color: color,
initiallyExpanded: expanded,
children: children ?? const [Text('item body', textDirection: TextDirection.ltr)],
),
),
),
),
));
);
await tester.pump();
}
@@ -70,7 +72,12 @@ void main() {
});
testWidgets('a single item still sits in the list (1-item collapser)', (tester) async {
await pump(tester, counter: '1 edit', children: const [Text('only item', textDirection: TextDirection.ltr)], expanded: true);
await pump(
tester,
counter: '1 edit',
children: const [Text('only item', textDirection: TextDirection.ltr)],
expanded: true,
);
expect(find.text('only item'), findsOneWidget);
expect(find.text('1 edit'), findsOneWidget);
});
@@ -98,15 +105,19 @@ void main() {
testWidgets('a deeper interactive control inside an item still fires (not swallowed)', (tester) async {
var tapped = false;
await pump(tester, expanded: true, children: [
ClideTappable(
onTap: () => tapped = true,
builder: (_, __, ___) => const Padding(
padding: EdgeInsets.all(8),
child: Text('press me', textDirection: TextDirection.ltr),
await pump(
tester,
expanded: true,
children: [
ClideTappable(
onTap: () => tapped = true,
builder: (_, _, _) => const Padding(
padding: EdgeInsets.all(8),
child: Text('press me', textDirection: TextDirection.ltr),
),
),
),
]);
],
);
await tester.tap(find.text('press me'));
await tester.pump();
expect(tapped, isTrue); // the item's own control won the hit
@@ -115,9 +126,7 @@ void main() {
testWidgets('the ticker is keyboard-focusable and toggles on Activate (a11y)', (tester) async {
await pump(tester);
final focusWidget = tester.widget<Focus>(
find.ancestor(of: find.text('clide_markdown.dart'), matching: find.byType(Focus)).first,
);
final focusWidget = tester.widget<Focus>(find.ancestor(of: find.text('clide_markdown.dart'), matching: find.byType(Focus)).first);
focusWidget.focusNode!.requestFocus();
await tester.pump();
expect(focusWidget.focusNode!.hasFocus, isTrue);
+1 -3
View File
@@ -19,9 +19,7 @@ void main() {
});
testWidgets('vertical axis yields a width-constrained container', (tester) async {
await tester.pumpWidget(
harness(f, const ClideDivider(axis: Axis.vertical, thickness: 2)),
);
await tester.pumpWidget(harness(f, const ClideDivider(axis: Axis.vertical, thickness: 2)));
final c = tester.widget<Container>(find.byType(Container));
expect(c.constraints?.maxWidth, 2.0);
});
+2 -6
View File
@@ -12,9 +12,7 @@ void main() {
tearDown(() async => f.dispose());
testWidgets('sizes a SizedBox + CustomPaint to the given size', (tester) async {
await tester.pumpWidget(
harness(f, const ClideIcon(FolderIcon(), size: 24)),
);
await tester.pumpWidget(harness(f, const ClideIcon(FolderIcon(), size: 24)));
final sb = tester.widget<SizedBox>(find.byType(SizedBox).first);
expect(sb.width, 24);
expect(sb.height, 24);
@@ -22,9 +20,7 @@ void main() {
});
testWidgets('default color is globalForeground', (tester) async {
await tester.pumpWidget(
harness(f, const ClideIcon(CheckIcon())),
);
await tester.pumpWidget(harness(f, const ClideIcon(CheckIcon())));
// Color is inaccessible after painting; ensure it renders without crashing.
expect(find.byType(ClideIcon), findsOneWidget);
});
@@ -17,21 +17,23 @@ void main() {
// Resolves only the one known repo path; everything else is "not a file".
String? resolve(String p) => p == 'lib/app.dart' ? '/repo/lib/app.dart' : null;
ClideMarkdown md(String src, {void Function(String, int?)? onOpen}) => ClideMarkdown(
src,
resolveFileRef: resolve,
onOpenFile: onOpen ?? (_, __) {},
);
ClideMarkdown md(String src, {void Function(String, int?)? onOpen}) => ClideMarkdown(src, resolveFileRef: resolve, onOpenFile: onOpen ?? (_, _) {});
testWidgets('a bare repo path in prose is tappable and opens with no line', (tester) async {
String? path;
int? line = -1;
await tester.pumpWidget(harness(
await tester.pumpWidget(
harness(
f,
md('see lib/app.dart for the entry point', onOpen: (p, l) {
path = p;
line = l;
})));
md(
'see lib/app.dart for the entry point',
onOpen: (p, l) {
path = p;
line = l;
},
),
),
);
await tester.pump();
await tester.tap(find.text('lib/app.dart'));
@@ -43,12 +45,18 @@ void main() {
testWidgets('a path:line ref opens at the line', (tester) async {
String? path;
int? line;
await tester.pumpWidget(harness(
await tester.pumpWidget(
harness(
f,
md('crash at lib/app.dart:42 today', onOpen: (p, l) {
path = p;
line = l;
})));
md(
'crash at lib/app.dart:42 today',
onOpen: (p, l) {
path = p;
line = l;
},
),
),
);
await tester.pump();
await tester.tap(find.text('lib/app.dart:42'));
@@ -70,12 +78,18 @@ void main() {
testWidgets('a backticked path is clickable', (tester) async {
String? path;
int? line;
await tester.pumpWidget(harness(
await tester.pumpWidget(
harness(
f,
md('open `lib/app.dart:7`', onOpen: (p, l) {
path = p;
line = l;
})));
md(
'open `lib/app.dart:7`',
onOpen: (p, l) {
path = p;
line = l;
},
),
),
);
await tester.pump();
await tester.tap(find.text('lib/app.dart:7'));
@@ -96,7 +110,7 @@ void main() {
testWidgets('a path that does not resolve stays literal (no link, no fire)', (tester) async {
var calls = 0;
await tester.pumpWidget(harness(f, md('nothing at lib/ghost.dart here', onOpen: (_, __) => calls++)));
await tester.pumpWidget(harness(f, md('nothing at lib/ghost.dart here', onOpen: (_, _) => calls++)));
await tester.pumpAndSettle();
// Rendered as plain text — tapping it does nothing.
@@ -107,7 +121,7 @@ void main() {
testWidgets('dotted prose (a version number) does not linkify', (tester) async {
var calls = 0;
await tester.pumpWidget(harness(f, md('shipped version 2.2.0 today', onOpen: (_, __) => calls++)));
await tester.pumpWidget(harness(f, md('shipped version 2.2.0 today', onOpen: (_, _) => calls++)));
await tester.pumpAndSettle();
await tester.tap(find.textContaining('2.2.0'), warnIfMissed: false);
+6 -9
View File
@@ -9,16 +9,13 @@ import 'package:flutter_test/flutter_test.dart';
import '../../helpers/widget_harness.dart';
Widget _boxed(double width, Widget child) => Directionality(
textDirection: TextDirection.ltr,
child: Center(
child: SizedBox(width: width, height: 16, child: child),
),
);
textDirection: TextDirection.ltr,
child: Center(
child: SizedBox(width: width, height: 16, child: child),
),
);
Widget _reducedMotion(double width, Widget child) => MediaQuery(
data: const MediaQueryData(disableAnimations: true),
child: _boxed(width, child),
);
Widget _reducedMotion(double width, Widget child) => MediaQuery(data: const MediaQueryData(disableAnimations: true), child: _boxed(width, child));
void main() {
testWidgets('shows the child statically when it fits', (tester) async {
+52 -36
View File
@@ -59,17 +59,19 @@ void main() {
testWidgets('renders items + separator; tapping an item fires onSelect + onClose', (tester) async {
var picked = '';
var closed = 0;
await tester.pumpWidget(harness(
f,
ClideMenu(
onClose: () => closed++,
entries: [
ClideMenuItem(label: 'Alpha', onSelect: () => picked = 'Alpha'),
const ClideMenuSeparator(),
ClideMenuItem(label: 'Beta', onSelect: () => picked = 'Beta'),
],
await tester.pumpWidget(
harness(
f,
ClideMenu(
onClose: () => closed++,
entries: [
ClideMenuItem(label: 'Alpha', onSelect: () => picked = 'Alpha'),
const ClideMenuSeparator(),
ClideMenuItem(label: 'Beta', onSelect: () => picked = 'Beta'),
],
),
),
));
);
await tester.pump();
expect(find.text('Alpha'), findsOneWidget);
expect(find.text('Beta'), findsOneWidget);
@@ -81,16 +83,18 @@ void main() {
testWidgets('arrow-down then Enter activates the highlighted item', (tester) async {
var picked = '';
await tester.pumpWidget(harness(
f,
ClideMenu(
onClose: () {},
entries: [
ClideMenuItem(label: 'One', onSelect: () => picked = 'One'),
ClideMenuItem(label: 'Two', onSelect: () => picked = 'Two'),
],
await tester.pumpWidget(
harness(
f,
ClideMenu(
onClose: () {},
entries: [
ClideMenuItem(label: 'One', onSelect: () => picked = 'One'),
ClideMenuItem(label: 'Two', onSelect: () => picked = 'Two'),
],
),
),
));
);
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
@@ -101,17 +105,19 @@ void main() {
testWidgets('disabled item is inert and skipped by arrows', (tester) async {
var picked = '';
await tester.pumpWidget(harness(
f,
ClideMenu(
onClose: () {},
entries: [
ClideMenuItem(label: 'Able', onSelect: () => picked = 'Able'),
ClideMenuItem(label: 'Off', enabled: false, onSelect: () => picked = 'Off'),
ClideMenuItem(label: 'Last', onSelect: () => picked = 'Last'),
],
await tester.pumpWidget(
harness(
f,
ClideMenu(
onClose: () {},
entries: [
ClideMenuItem(label: 'Able', onSelect: () => picked = 'Able'),
ClideMenuItem(label: 'Off', enabled: false, onSelect: () => picked = 'Off'),
ClideMenuItem(label: 'Last', onSelect: () => picked = 'Last'),
],
),
),
));
);
await tester.pump();
await tester.tap(find.text('Off'));
await tester.pump();
@@ -125,7 +131,15 @@ void main() {
testWidgets('Escape calls onClose', (tester) async {
var closed = 0;
await tester.pumpWidget(harness(f, ClideMenu(onClose: () => closed++, entries: [ClideMenuItem(label: 'X', onSelect: () {})])));
await tester.pumpWidget(
harness(
f,
ClideMenu(
onClose: () => closed++,
entries: [ClideMenuItem(label: 'X', onSelect: () {})],
),
),
);
await tester.pump();
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pump();
@@ -135,13 +149,15 @@ void main() {
testWidgets('keepOpenOnSelect item fires onSelect but not onClose', (tester) async {
var picked = 0;
var closed = 0;
await tester.pumpWidget(harness(
f,
ClideMenu(
onClose: () => closed++,
entries: [ClideMenuItem(label: 'Toggle', keepOpenOnSelect: true, onSelect: () => picked++)],
await tester.pumpWidget(
harness(
f,
ClideMenu(
onClose: () => closed++,
entries: [ClideMenuItem(label: 'Toggle', keepOpenOnSelect: true, onSelect: () => picked++)],
),
),
));
);
await tester.pump();
await tester.tap(find.text('Toggle'));
await tester.pump();
+47 -39
View File
@@ -17,14 +17,16 @@ void main() {
tearDown(() => f.dispose());
testWidgets('conveys its status while its contribution is focused, clears on blur', (tester) async {
await tester.pumpWidget(harness(
f,
const ClidePane(
contributionId: 'pane.x',
statusWidget: Text('S', textDirection: TextDirection.ltr),
child: SizedBox(),
await tester.pumpWidget(
harness(
f,
const ClidePane(
contributionId: 'pane.x',
statusWidget: Text('S', textDirection: TextDirection.ltr),
child: SizedBox(),
),
),
));
);
final focus = f.services.focus;
expect(focus.activeStatusWidget, isNull); // not focused yet
@@ -43,34 +45,38 @@ void main() {
// mid-build ("markNeedsBuild during build"). A focus listener must be in
// the tree to reproduce it.
f.services.focus.setActive(slot: Slots.workspace, contributionId: 'pane.x');
await tester.pumpWidget(harness(
f,
const Column(
children: [
PaneContextStatusItem(),
ClidePane(
contributionId: 'pane.x',
statusWidget: Text('S', textDirection: TextDirection.ltr),
child: SizedBox(),
),
],
await tester.pumpWidget(
harness(
f,
const Column(
children: [
PaneContextStatusItem(),
ClidePane(
contributionId: 'pane.x',
statusWidget: Text('S', textDirection: TextDirection.ltr),
child: SizedBox(),
),
],
),
),
));
);
await tester.pump(); // run the deferred post-frame convey
expect(tester.takeException(), isNull);
expect(f.services.focus.activeStatusWidget, isA<Text>());
});
testWidgets('an inactive pane never conveys', (tester) async {
await tester.pumpWidget(harness(
f,
const ClidePane(
contributionId: 'pane.x',
active: false,
statusWidget: Text('S', textDirection: TextDirection.ltr),
child: SizedBox(),
await tester.pumpWidget(
harness(
f,
const ClidePane(
contributionId: 'pane.x',
active: false,
statusWidget: Text('S', textDirection: TextDirection.ltr),
child: SizedBox(),
),
),
));
);
f.services.focus.setActive(slot: Slots.workspace, contributionId: 'pane.x');
await tester.pump();
expect(f.services.focus.activeStatusWidget, isNull);
@@ -80,19 +86,21 @@ void main() {
f.services.focus.setActive(slot: Slots.workspace, contributionId: 'pane.x');
var label = 'A';
late StateSetter setLabel;
await tester.pumpWidget(harness(
f,
StatefulBuilder(
builder: (ctx, setState) {
setLabel = setState;
return ClidePane(
contributionId: 'pane.x',
statusWidget: Text(label, textDirection: TextDirection.ltr),
child: const SizedBox(),
);
},
await tester.pumpWidget(
harness(
f,
StatefulBuilder(
builder: (ctx, setState) {
setLabel = setState;
return ClidePane(
contributionId: 'pane.x',
statusWidget: Text(label, textDirection: TextDirection.ltr),
child: const SizedBox(),
);
},
),
),
));
);
await tester.pump();
expect((f.services.focus.activeStatusWidget! as Text).data, 'A');
+21 -12
View File
@@ -137,12 +137,20 @@ void main() {
// Both the paragraph text and the code text should appear in the
// clipboard. SelectableRegion concatenates selectables with newlines.
final copied = clipboard.text ?? '';
expect(copied, contains(paraText),
reason: 'paragraph text must be selectable via SelectableRegion after '
'converting ClideMarkdown from RichText to Text.rich');
expect(copied, contains(codeText),
reason: 'code block text must be selectable via SelectableRegion after '
'converting ClideCodeBlock from RichText to Text.rich');
expect(
copied,
contains(paraText),
reason:
'paragraph text must be selectable via SelectableRegion after '
'converting ClideMarkdown from RichText to Text.rich',
);
expect(
copied,
contains(codeText),
reason:
'code block text must be selectable via SelectableRegion after '
'converting ClideCodeBlock from RichText to Text.rich',
);
});
testWidgets('Text.rich nodes are owned by Text widgets, registering with SelectableRegion', (tester) async {
@@ -185,13 +193,14 @@ void main() {
// what Text.rich builds. A bare RichText(...) has no Text parent.
final richTexts = find.byType(RichText);
for (final el in richTexts.evaluate()) {
final textAncestor = find.ancestor(
of: find.byElementPredicate((e) => e == el),
matching: find.byType(Text),
final textAncestor = find.ancestor(of: find.byElementPredicate((e) => e == el), matching: find.byType(Text));
expect(
textAncestor,
findsAtLeastNWidgets(1),
reason:
'RichText for "${(el.widget as RichText).text.toPlainText()}" '
'should be owned by a Text (built via Text.rich, not bare RichText)',
);
expect(textAncestor, findsAtLeastNWidgets(1),
reason: 'RichText for "${(el.widget as RichText).text.toPlainText()}" '
'should be owned by a Text (built via Text.rich, not bare RichText)');
}
});
});
@@ -17,15 +17,17 @@ void main() {
// Disable animations so the spinner is static (no perpetual rotation) and
// pumpAndSettle can settle — mirroring how every animated widget behaves
// under reduced motion.
Future<void> pump(WidgetTester tester, Widget child) => tester.pumpWidget(harness(
f,
Builder(
builder: (ctx) => MediaQuery(
data: MediaQuery.of(ctx).copyWith(disableAnimations: true),
child: Center(child: child),
),
Future<void> pump(WidgetTester tester, Widget child) => tester.pumpWidget(
harness(
f,
Builder(
builder: (ctx) => MediaQuery(
data: MediaQuery.of(ctx).copyWith(disableAnimations: true),
child: Center(child: child),
),
));
),
),
);
Finder iconWith(Object painterType) => find.byWidgetPredicate((w) => w is ClideIcon && w.painter.runtimeType == painterType);
@@ -64,15 +66,19 @@ void main() {
// 'running' key. Pre-fix this threw "Duplicate keys found".
var status = ClideRunStatus.running;
late StateSetter set;
await tester.pumpWidget(harness(
f,
Center(
child: StatefulBuilder(builder: (ctx, s) {
set = s;
return ClideStatusIndicator(status: status);
}),
await tester.pumpWidget(
harness(
f,
Center(
child: StatefulBuilder(
builder: (ctx, s) {
set = s;
return ClideStatusIndicator(status: status);
},
),
),
),
));
);
void flip(ClideRunStatus next) => set(() => status = next);
+3 -15
View File
@@ -13,9 +13,7 @@ void main() {
tearDown(() async => f.dispose());
testWidgets('default background comes from panelBackground token', (tester) async {
await tester.pumpWidget(
harness(f, const ClideSurface(child: Text('x'))),
);
await tester.pumpWidget(harness(f, const ClideSurface(child: Text('x'))));
final container = tester.widget<Container>(find.byType(Container));
final decoration = container.decoration as BoxDecoration;
expect(decoration.color, f.services.theme.current.surface.panelBackground);
@@ -23,24 +21,14 @@ void main() {
testWidgets('explicit color overrides the token default', (tester) async {
const custom = Color(0xFF123456);
await tester.pumpWidget(
harness(f, const ClideSurface(color: custom, child: Text('x'))),
);
await tester.pumpWidget(harness(f, const ClideSurface(color: custom, child: Text('x'))));
final container = tester.widget<Container>(find.byType(Container));
final decoration = container.decoration as BoxDecoration;
expect(decoration.color, custom);
});
testWidgets('border when provided renders a BoxBorder', (tester) async {
await tester.pumpWidget(
harness(
f,
ClideSurface(
border: f.services.theme.current.surface.modalSurfaceBorder,
child: const Text('x'),
),
),
);
await tester.pumpWidget(harness(f, ClideSurface(border: f.services.theme.current.surface.modalSurfaceBorder, child: const Text('x'))));
final container = tester.widget<Container>(find.byType(Container));
final decoration = container.decoration as BoxDecoration;
expect(decoration.border, isNotNull);
+2 -14
View File
@@ -64,23 +64,11 @@ void main() {
final handle = tester.ensureSemantics();
expect(
tester.getSemantics(find.bySemanticsLabel('Files')),
matchesSemantics(
label: 'Files',
isButton: true,
isSelected: true,
hasSelectedState: true,
hasTapAction: true,
),
matchesSemantics(label: 'Files', isButton: true, isSelected: true, hasSelectedState: true, hasTapAction: true),
);
expect(
tester.getSemantics(find.bySemanticsLabel('Git')),
matchesSemantics(
label: 'Git',
isButton: true,
isSelected: false,
hasSelectedState: true,
hasTapAction: true,
),
matchesSemantics(label: 'Git', isButton: true, isSelected: false, hasSelectedState: true, hasTapAction: true),
);
handle.dispose();
});
+29 -33
View File
@@ -23,16 +23,14 @@ void main() {
var tapped = 0;
final node = FocusNode();
addTearDown(node.dispose);
await tester.pumpWidget(harness(
f,
Center(
child: ClideTappable(
focusNode: node,
onTap: () => tapped++,
builder: (_, hovered, pressed) => const SizedBox(width: 60, height: 24),
await tester.pumpWidget(
harness(
f,
Center(
child: ClideTappable(focusNode: node, onTap: () => tapped++, builder: (_, hovered, pressed) => const SizedBox(width: 60, height: 24)),
),
),
));
);
expect(node.hasFocus, isFalse);
node.requestFocus();
@@ -46,17 +44,19 @@ void main() {
var tapped = 0;
final node = FocusNode();
addTearDown(node.dispose);
await tester.pumpWidget(harness(
f,
Center(
child: ClideTappable(
focusNode: node,
autofocus: true,
onTap: () => tapped++,
builder: (_, hovered, pressed) => const SizedBox(width: 60, height: 24),
await tester.pumpWidget(
harness(
f,
Center(
child: ClideTappable(
focusNode: node,
autofocus: true,
onTap: () => tapped++,
builder: (_, hovered, pressed) => const SizedBox(width: 60, height: 24),
),
),
),
));
);
await tester.pump();
expect(node.hasFocus, isTrue);
// Dispatch ActivateIntent directly against the focused context
@@ -70,16 +70,14 @@ void main() {
testWidgets('disabled tappable (onTap == null) cannot receive focus', (tester) async {
final node = FocusNode();
addTearDown(node.dispose);
await tester.pumpWidget(harness(
f,
Center(
child: ClideTappable(
focusNode: node,
onTap: null,
builder: (_, hovered, pressed) => const SizedBox(width: 60, height: 24),
await tester.pumpWidget(
harness(
f,
Center(
child: ClideTappable(focusNode: node, onTap: null, builder: (_, hovered, pressed) => const SizedBox(width: 60, height: 24)),
),
),
));
);
node.requestFocus();
await tester.pump();
expect(node.hasFocus, isFalse, reason: 'canRequestFocus is false when onTap is null');
@@ -88,16 +86,14 @@ void main() {
testWidgets('focus ring appears when focused, gone when unfocused', (tester) async {
final node = FocusNode();
addTearDown(node.dispose);
await tester.pumpWidget(harness(
f,
Center(
child: ClideTappable(
focusNode: node,
onTap: () {},
builder: (_, hovered, pressed) => const SizedBox(width: 60, height: 24),
await tester.pumpWidget(
harness(
f,
Center(
child: ClideTappable(focusNode: node, onTap: () {}, builder: (_, hovered, pressed) => const SizedBox(width: 60, height: 24)),
),
),
));
);
Iterable<Color> ringColors() => tester
.widgetList<DecoratedBox>(find.descendant(of: find.byType(ClideTappable), matching: find.byType(DecoratedBox)))
+27 -26
View File
@@ -17,22 +17,26 @@ void main() {
tearDown(() async => f.dispose());
Widget host(Widget child) => Directionality(
textDirection: TextDirection.ltr,
child: ClideKernel(
services: f.services,
child: ClideTheme(
controller: f.services.theme,
child: Align(alignment: Alignment.topLeft, child: child),
),
),
);
textDirection: TextDirection.ltr,
child: ClideKernel(
services: f.services,
child: ClideTheme(
controller: f.services.theme,
child: Align(alignment: Alignment.topLeft, child: child),
),
),
);
testWidgets('renders the message and calls onDismiss when the × is tapped', (tester) async {
var dismissed = false;
await tester.pumpWidget(host(ClideToast(
entry: const ToastEntry(id: 1, message: 'Pushed to origin/main', severity: ToastSeverity.success),
onDismiss: () => dismissed = true,
)));
await tester.pumpWidget(
host(
ClideToast(
entry: const ToastEntry(id: 1, message: 'Pushed to origin/main', severity: ToastSeverity.success),
onDismiss: () => dismissed = true,
),
),
);
await tester.pump(const Duration(milliseconds: 300)); // settle entrance
expect(find.text('Pushed to origin/main'), findsOneWidget);
@@ -43,26 +47,23 @@ void main() {
});
testWidgets('exposes the message as a live region (a11y)', (tester) async {
await tester.pumpWidget(host(ClideToast(
entry: const ToastEntry(id: 1, message: 'Heads up', severity: ToastSeverity.warning),
onDismiss: () {},
)));
await tester.pumpWidget(
host(
ClideToast(
entry: const ToastEntry(id: 1, message: 'Heads up', severity: ToastSeverity.warning),
onDismiss: () {},
),
),
);
await tester.pump(const Duration(milliseconds: 300));
// The message is wrapped in a live-region Semantics so screen readers
// announce it when it appears.
expect(
find.byWidgetPredicate((w) => w is Semantics && w.properties.liveRegion == true && w.properties.label == 'Heads up'),
findsOneWidget,
);
expect(find.byWidgetPredicate((w) => w is Semantics && w.properties.liveRegion == true && w.properties.label == 'Heads up'), findsOneWidget);
});
testWidgets('overlay renders a card per queued toast and dismiss removes one', (tester) async {
await tester.pumpWidget(host(const SizedBox(
width: 800,
height: 600,
child: Stack(children: [ToastOverlay()]),
)));
await tester.pumpWidget(host(const SizedBox(width: 800, height: 600, child: Stack(children: [ToastOverlay()]))));
await tester.pump();
expect(find.byType(ClideToast), findsNothing);
+1 -6
View File
@@ -112,12 +112,7 @@ void main() {
// The Positioned ancestor of the tooltip uses `bottom:` (above-mode),
// not `top:`, when there isn't enough room below.
final positioned = tester.widget<Positioned>(
find.ancestor(
of: find.text('above'),
matching: find.byType(Positioned),
),
);
final positioned = tester.widget<Positioned>(find.ancestor(of: find.text('above'), matching: find.byType(Positioned)));
expect(positioned.bottom, isNotNull);
expect(positioned.top, isNull);
});
+16 -14
View File
@@ -19,21 +19,23 @@ void main() {
var picked = '';
List<String> sugg = ['alice', 'bob'];
late StateSetter setOuter;
await tester.pumpWidget(anchoredHarness(
f,
StatefulBuilder(
builder: (ctx, setState) {
setOuter = setState;
return ClideTypeahead(
suggestions: sugg,
onSelect: (v) => picked = v,
formatLabel: (n) => '@$n',
child: const SizedBox(width: 200, height: 24, child: ClideText('field')),
);
},
await tester.pumpWidget(
anchoredHarness(
f,
StatefulBuilder(
builder: (ctx, setState) {
setOuter = setState;
return ClideTypeahead(
suggestions: sugg,
onSelect: (v) => picked = v,
formatLabel: (n) => '@$n',
child: const SizedBox(width: 200, height: 24, child: ClideText('field')),
);
},
),
alignment: Alignment.topLeft,
),
alignment: Alignment.topLeft,
));
);
await tester.pumpAndSettle();
expect(find.text('@alice'), findsOneWidget);
expect(find.text('@bob'), findsOneWidget);
+166 -159
View File
@@ -34,19 +34,23 @@ void main() {
});
testWidgets('opens to show an input field + the registered commands', (tester) async {
f.services.commands.register(CommandContribution(
id: 'c1',
command: 'cmd.one',
title: 'Command One',
defaultBinding: 'ctrl+1',
run: (_) async => IpcResponse.ok(id: '', data: const {}),
));
f.services.commands.register(CommandContribution(
id: 'c2',
command: 'cmd.two',
title: 'Command Two',
run: (_) async => IpcResponse.ok(id: '', data: const {}),
));
f.services.commands.register(
CommandContribution(
id: 'c1',
command: 'cmd.one',
title: 'Command One',
defaultBinding: 'ctrl+1',
run: (_) async => IpcResponse.ok(id: '', data: const {}),
),
);
f.services.commands.register(
CommandContribution(
id: 'c2',
command: 'cmd.two',
title: 'Command Two',
run: (_) async => IpcResponse.ok(id: '', data: const {}),
),
);
f.services.palette.open();
await tester.pumpWidget(harness(f, Stack(children: const [ClidePalette()])));
await tester.pumpAndSettle();
@@ -57,15 +61,17 @@ void main() {
testWidgets('tapping a row invokes the command + closes the palette', (tester) async {
var invocations = 0;
f.services.commands.register(CommandContribution(
id: 'c1',
command: 'tap.target',
title: 'Tap Target',
run: (_) async {
invocations++;
return IpcResponse.ok(id: '', data: const {});
},
));
f.services.commands.register(
CommandContribution(
id: 'c1',
command: 'tap.target',
title: 'Tap Target',
run: (_) async {
invocations++;
return IpcResponse.ok(id: '', data: const {});
},
),
);
f.services.palette.open();
await tester.pumpWidget(harness(f, Stack(children: const [ClidePalette()])));
await tester.pumpAndSettle();
@@ -75,18 +81,22 @@ void main() {
});
testWidgets('typing narrows the visible commands via palette.setFilter', (tester) async {
f.services.commands.register(CommandContribution(
id: 'c1',
command: 'alpha.cmd',
title: 'Alpha',
run: (_) async => IpcResponse.ok(id: '', data: const {}),
));
f.services.commands.register(CommandContribution(
id: 'c2',
command: 'beta.cmd',
title: 'Beta',
run: (_) async => IpcResponse.ok(id: '', data: const {}),
));
f.services.commands.register(
CommandContribution(
id: 'c1',
command: 'alpha.cmd',
title: 'Alpha',
run: (_) async => IpcResponse.ok(id: '', data: const {}),
),
);
f.services.commands.register(
CommandContribution(
id: 'c2',
command: 'beta.cmd',
title: 'Beta',
run: (_) async => IpcResponse.ok(id: '', data: const {}),
),
);
f.services.palette.open();
await tester.pumpWidget(harness(f, Stack(children: const [ClidePalette()])));
await tester.pumpAndSettle();
@@ -99,21 +109,25 @@ void main() {
testWidgets('submitting the input invokes the first filtered command', (tester) async {
var invocations = 0;
f.services.commands.register(CommandContribution(
id: 'c1',
command: 'submit.target',
title: 'Submit Target',
run: (_) async {
invocations++;
return IpcResponse.ok(id: '', data: const {});
},
));
f.services.commands.register(CommandContribution(
id: 'c2',
command: 'other.cmd',
title: 'Other',
run: (_) async => IpcResponse.ok(id: '', data: const {}),
));
f.services.commands.register(
CommandContribution(
id: 'c1',
command: 'submit.target',
title: 'Submit Target',
run: (_) async {
invocations++;
return IpcResponse.ok(id: '', data: const {});
},
),
);
f.services.commands.register(
CommandContribution(
id: 'c2',
command: 'other.cmd',
title: 'Other',
run: (_) async => IpcResponse.ok(id: '', data: const {}),
),
);
f.services.palette.open();
await tester.pumpWidget(harness(f, Stack(children: const [ClidePalette()])));
await tester.pumpAndSettle();
@@ -126,12 +140,14 @@ void main() {
});
testWidgets('hovering a palette row updates its hover state', (tester) async {
f.services.commands.register(CommandContribution(
id: 'c1',
command: 'hover.cmd',
title: 'Hoverable',
run: (_) async => IpcResponse.ok(id: '', data: const {}),
));
f.services.commands.register(
CommandContribution(
id: 'c1',
command: 'hover.cmd',
title: 'Hoverable',
run: (_) async => IpcResponse.ok(id: '', data: const {}),
),
);
f.services.palette.open();
await tester.pumpWidget(harness(f, Stack(children: const [ClidePalette()])));
await tester.pumpAndSettle();
@@ -149,12 +165,14 @@ void main() {
testWidgets('arrow keys move the highlighted command (T-100)', (tester) async {
for (final id in ['a', 'b', 'c']) {
f.services.commands.register(CommandContribution(
id: id,
command: 'cmd.$id',
title: 'Item $id',
run: (_) async => IpcResponse.ok(id: '', data: const {}),
));
f.services.commands.register(
CommandContribution(
id: id,
command: 'cmd.$id',
title: 'Item $id',
run: (_) async => IpcResponse.ok(id: '', data: const {}),
),
);
}
f.services.palette.open();
await tester.pumpWidget(harness(f, Stack(children: const [ClidePalette()])));
@@ -181,15 +199,17 @@ void main() {
testWidgets('PaletteAcceptIntent invokes the highlighted command (T-100)', (tester) async {
var which = '';
for (final id in ['a', 'b', 'c']) {
f.services.commands.register(CommandContribution(
id: id,
command: 'cmd.$id',
title: 'Item $id',
run: (_) async {
which = id;
return IpcResponse.ok(id: '', data: const {});
},
));
f.services.commands.register(
CommandContribution(
id: id,
command: 'cmd.$id',
title: 'Item $id',
run: (_) async {
which = id;
return IpcResponse.ok(id: '', data: const {});
},
),
);
}
f.services.palette.open();
await tester.pumpWidget(harness(f, Stack(children: const [ClidePalette()])));
@@ -204,12 +224,14 @@ void main() {
});
testWidgets('DismissIntent closes the palette (T-100)', (tester) async {
f.services.commands.register(CommandContribution(
id: 'c1',
command: 'thing',
title: 'Thing',
run: (_) async => IpcResponse.ok(id: '', data: const {}),
));
f.services.commands.register(
CommandContribution(
id: 'c1',
command: 'thing',
title: 'Thing',
run: (_) async => IpcResponse.ok(id: '', data: const {}),
),
);
f.services.palette.open();
await tester.pumpWidget(harness(f, Stack(children: const [ClidePalette()])));
await tester.pumpAndSettle();
@@ -240,13 +262,7 @@ void main() {
testWidgets('debounces onChanged calls to the configured duration', (tester) async {
final values = <String>[];
await tester.pumpWidget(harness(
f,
ClideFilterBox(
onChanged: values.add,
debounce: const Duration(milliseconds: 50),
),
));
await tester.pumpWidget(harness(f, ClideFilterBox(onChanged: values.add, debounce: const Duration(milliseconds: 50))));
// Type into the editable text field.
await tester.enterText(find.byType(EditableText), 'abc');
// Before the debounce elapses no call.
@@ -259,13 +275,7 @@ void main() {
testWidgets('clear button appears once the field has text and resets onChanged when tapped', (tester) async {
final values = <String>[];
await tester.pumpWidget(harness(
f,
ClideFilterBox(
onChanged: values.add,
debounce: const Duration(milliseconds: 10),
),
));
await tester.pumpWidget(harness(f, ClideFilterBox(onChanged: values.add, debounce: const Duration(milliseconds: 10))));
// No clear icon when the field is empty.
expect(find.byType(GestureDetector), findsNothing);
await tester.enterText(find.byType(EditableText), 'foo');
@@ -280,13 +290,7 @@ void main() {
testWidgets('onSubmitted fires when the field is submitted', (tester) async {
String? submitted;
await tester.pumpWidget(harness(
f,
ClideFilterBox(
onChanged: (_) {},
onSubmitted: (v) => submitted = v,
),
));
await tester.pumpWidget(harness(f, ClideFilterBox(onChanged: (_) {}, onSubmitted: (v) => submitted = v)));
await tester.enterText(find.byType(EditableText), 'submit-me');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
@@ -294,10 +298,7 @@ void main() {
});
testWidgets('renders the hint as a placeholder (shown empty, hidden once typed); icon optional', (tester) async {
await tester.pumpWidget(harness(
f,
ClideFilterBox(onChanged: (_) {}, hint: 'Replace', showIcon: false),
));
await tester.pumpWidget(harness(f, ClideFilterBox(onChanged: (_) {}, hint: 'Replace', showIcon: false)));
// Placeholder visible while empty; the icon slot is kept for alignment but
// draws a blank glyph (showIcon: false → EmptyIconPainter).
expect(find.text('Replace'), findsOneWidget);
@@ -318,14 +319,21 @@ void main() {
testWidgets('left / center / right factories render', (tester) async {
final wc = WindowControls();
addTearDown(wc.dispose);
await tester.pumpWidget(harness(
f,
Column(children: [
SizedBox(width: 200, child: ColumnHat.left(windowControls: wc)),
SizedBox(width: 200, child: ColumnHat.center(windowControls: wc, project: 'clide', branch: 'main')),
SizedBox(width: 200, child: ColumnHat.right(windowControls: wc)),
]),
));
await tester.pumpWidget(
harness(
f,
Column(
children: [
SizedBox(width: 200, child: ColumnHat.left(windowControls: wc)),
SizedBox(
width: 200,
child: ColumnHat.center(windowControls: wc, project: 'clide', branch: 'main'),
),
SizedBox(width: 200, child: ColumnHat.right(windowControls: wc)),
],
),
),
);
// Center hat renders the joined label.
expect(find.text('clide > main'), findsOneWidget);
});
@@ -333,10 +341,7 @@ void main() {
testWidgets('center hat falls back to "clide" with no project/branch', (tester) async {
final wc = WindowControls();
addTearDown(wc.dispose);
await tester.pumpWidget(harness(
f,
SizedBox(width: 200, child: ColumnHat.center(windowControls: wc)),
));
await tester.pumpWidget(harness(f, SizedBox(width: 200, child: ColumnHat.center(windowControls: wc))));
expect(find.text('clide'), findsOneWidget);
});
});
@@ -348,17 +353,19 @@ void main() {
testWidgets('renders one button per item, marks the active one, fires onSelect', (tester) async {
String? selected;
await tester.pumpWidget(harness(
f,
ClideIconRail(
items: [
ClideIconRailItem(id: 'a', icon: PhosphorIcons.byName('folder'), tooltip: 'A'),
ClideIconRailItem(id: 'b', icon: PhosphorIcons.byName('git-branch'), tooltip: 'B'),
],
activeId: 'a',
onSelect: (id) => selected = id,
await tester.pumpWidget(
harness(
f,
ClideIconRail(
items: [
ClideIconRailItem(id: 'a', icon: PhosphorIcons.byName('folder'), tooltip: 'A'),
ClideIconRailItem(id: 'b', icon: PhosphorIcons.byName('git-branch'), tooltip: 'B'),
],
activeId: 'a',
onSelect: (id) => selected = id,
),
),
));
);
await tester.pumpAndSettle();
// Each item exposes a tooltip-labeled semantics button.
expect(find.bySemanticsLabel('A'), findsOneWidget);
@@ -368,20 +375,20 @@ void main() {
});
testWidgets('scrolls instead of overflowing when items exceed the width', (tester) async {
await tester.pumpWidget(harness(
f,
SizedBox(
width: 120,
height: 30,
child: ClideIconRail(
items: [
for (var i = 0; i < 10; i++) ClideIconRailItem(id: '$i', icon: PhosphorIcons.byName('folder'), tooltip: 'Tab $i'),
],
activeId: '0',
onSelect: (_) {},
await tester.pumpWidget(
harness(
f,
SizedBox(
width: 120,
height: 30,
child: ClideIconRail(
items: [for (var i = 0; i < 10; i++) ClideIconRailItem(id: '$i', icon: PhosphorIcons.byName('folder'), tooltip: 'Tab $i')],
activeId: '0',
onSelect: (_) {},
),
),
),
));
);
await tester.pumpAndSettle();
// No RenderFlex overflow (would surface as a thrown FlutterError),
// and the rail is horizontally scrollable.
@@ -397,17 +404,16 @@ void main() {
testWidgets('renders the rotated label + a badge dot when badgeCount > 0', (tester) async {
var taps = 0;
await tester.pumpWidget(harness(
f,
SizedBox(
await tester.pumpWidget(
harness(
f,
SizedBox(
height: 200,
width: ClideSpine.width,
child: ClideSpine(
label: 'SIDEBAR',
badgeCount: 3,
onExpand: () => taps++,
)),
));
child: ClideSpine(label: 'SIDEBAR', badgeCount: 3, onExpand: () => taps++),
),
),
);
await tester.pumpAndSettle();
expect(find.text('SIDEBAR'), findsOneWidget);
// Tap to expand.
@@ -416,17 +422,16 @@ void main() {
});
testWidgets('renders the right-side variant without throwing', (tester) async {
await tester.pumpWidget(harness(
f,
SizedBox(
await tester.pumpWidget(
harness(
f,
SizedBox(
height: 200,
width: ClideSpine.width,
child: ClideSpine(
label: 'CTX',
side: SpineSide.right,
onExpand: () {},
)),
));
child: ClideSpine(label: 'CTX', side: SpineSide.right, onExpand: () {}),
),
),
);
await tester.pumpAndSettle();
expect(find.text('CTX'), findsOneWidget);
});
@@ -440,13 +445,15 @@ void main() {
testWidgets('wraps a child and renders 8 resize zones', (tester) async {
final wc = WindowControls();
addTearDown(wc.dispose);
await tester.pumpWidget(harness(
f,
ClideResizeBorder(
windowControls: wc,
child: const SizedBox.expand(child: ColoredBox(color: Color(0xFF000000))),
await tester.pumpWidget(
harness(
f,
ClideResizeBorder(
windowControls: wc,
child: const SizedBox.expand(child: ColoredBox(color: Color(0xFF000000))),
),
),
));
);
await tester.pumpAndSettle();
// 4 corners + 4 edges = 8 zones each with a MouseRegion.
expect(find.byType(MouseRegion).evaluate().length, greaterThanOrEqualTo(8));