Files
clide/test/widgets/multitab_pane_test.dart
T
jpmschweitzerandClaude aa742eed79 MultitabPane: drag-to-reorder gesture wiring (T-84)
Each tab is wrapped in a Draggable (when allowReorder is true and the
entry itself is reorderable) and a DragTarget (always — the controller's
barrier logic decides whether the move actually happens). Drops insert
the dragged entry at the target tab's index. A 2px leading insertion
indicator highlights the active drop target.

The widget harness now wraps children in an Overlay so Draggable's
feedback can mount without each test re-wrapping. Sized by the test
view's bounds to avoid disturbing existing tests that query
find.byType(SizedBox).first.

Four widget tests cover the gesture path: drop reorders, pinned
barrier blocks, pinned tabs aren't draggable, and allowReorder=false
disables drag entirely.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-05-06 14:17:16 +02:00

247 lines
8.5 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
MultitabEntry<String> entry(String id, {bool closeable = true, bool reorderable = true}) {
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}'));
void main() {
group('MultitabPane', () {
late KernelFixture f;
setUp(() async => f = await KernelFixture.create());
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)),
);
expect(find.text('primary'), findsOneWidget);
expect(find.text('s1'), findsOneWidget);
expect(find.text('s2'), findsOneWidget);
// Body of the active (first) tab is visible.
expect(find.byKey(const ValueKey('body-primary')), findsOneWidget);
expect(find.byKey(const ValueKey('body-s1')), findsNothing);
});
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)),
);
expect(find.byKey(const ValueKey('body-a')), findsOneWidget);
await tester.tap(find.text('b'));
await tester.pumpAndSettle();
expect(c.activeId, 'b');
expect(find.byKey(const ValueKey('body-b')), findsOneWidget);
expect(find.byKey(const ValueKey('body-a')), findsNothing);
});
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.tap(find.bySemanticsLabel('New tab'));
await tester.pumpAndSettle();
expect(added, 1);
});
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)),
);
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)),
);
// 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'.
expect(find.text('×'), findsOneWidget);
});
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)),
);
await tester.tap(find.text('×'));
await tester.pumpAndSettle();
expect(c.entries.map((e) => e.id), ['p']);
});
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,
),
),
);
// Both tabs are closeable; tap the first × encountered.
await tester.tap(find.text('×').first);
await tester.pumpAndSettle();
expect(closed, isNotNull);
// Host decided not to remove yet — entries are unchanged.
expect(c.entries.length, 2);
});
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)),
);
expect(find.bySemanticsLabel('b'), findsNothing);
c.add(entry('b'));
await tester.pumpAndSettle();
expect(find.bySemanticsLabel('b'), findsOneWidget);
});
testWidgets('empty controller renders no body', (tester) async {
final c = MultitabController<String>();
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)),
);
// Drag tab 'a' to where tab 'c' sits.
final from = tester.getCenter(find.text('a'));
final to = tester.getCenter(find.text('c'));
final gesture = await tester.startGesture(from);
await tester.pump(const Duration(milliseconds: 100));
await gesture.moveTo(to);
await tester.pump(const Duration(milliseconds: 100));
await gesture.up();
await tester.pumpAndSettle();
expect(c.entries.map((e) => e.id), ['b', 'c', 'a']);
});
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)),
);
// Try to drag 'a' before pinned 'p' — controller's barrier
// logic should reject and the order stays.
final from = tester.getCenter(find.text('a'));
final to = tester.getCenter(find.text('p'));
final gesture = await tester.startGesture(from);
await tester.pump(const Duration(milliseconds: 100));
await gesture.moveTo(to);
await tester.pump(const Duration(milliseconds: 100));
await gesture.up();
await tester.pumpAndSettle();
expect(c.entries.map((e) => e.id), ['p', 'a', 'b']);
});
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)),
);
// Attempt to drag pinned 'p' to position of 'a'.
final from = tester.getCenter(find.text('p'));
final to = tester.getCenter(find.text('a'));
final gesture = await tester.startGesture(from);
await tester.pump(const Duration(milliseconds: 100));
await gesture.moveTo(to);
await tester.pump(const Duration(milliseconds: 100));
await gesture.up();
await tester.pumpAndSettle();
// Order unchanged; pinned tab refused to be dragged.
expect(c.entries.map((e) => e.id), ['p', 'a']);
});
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,
),
),
);
final from = tester.getCenter(find.text('a'));
final to = tester.getCenter(find.text('b'));
final gesture = await tester.startGesture(from);
await tester.pump(const Duration(milliseconds: 100));
await gesture.moveTo(to);
await tester.pump(const Duration(milliseconds: 100));
await gesture.up();
await tester.pumpAndSettle();
expect(c.entries.map((e) => e.id), ['a', 'b']);
});
});
}